In the previous two posts, we defined what we want our model of Tetris in Blazor to look like, and we created classes such as Grid
and CellCollection
to help model the game.
In this post, we are going to write up the C# classes that will define each tetromino, as well as enumerations for their style and orientation, their common base class, and a generator class that we will use to populate the "upcoming pieces" display in our game.
It's a lot of work, but it'll be super cool when we're done. Let's go!
The Sample Project
Don't forget to check out the entire BlazorGames repository over on GitHub!
Previously in this Series
Style and Orientation
A tetromino is a set of four (4) cells that moves as a unit on the Tetris game grid. In Tetris, tetrominos have the following properties:
- Each tetromino is a different color.
- The tetromino can be moved left or right on the grid.
- The tetromino can be "hard dropped" to the bottom of the play area.
- Tetrominos can be rotated about a central point.
- The tetromino automatically drops one row after a certain amount of time has elapsed.
Note: In a real game of Tetris, tetrominos can also be "soft dropped" or moved down several rows rather than dropping all the way down. My original game had this, but it was not very nice to use and didn't display well. So, we're leaving out that functionality. If one of my dear readers would like to implement it, test it, and submit a PR, it would be welcomed.
Here's all the possible tetrominos:
As you can see, there are seven tetrominos in Tetris: block, T-shaped, L-shaped, reverse L-shaped, straight, left zig-zag, and right zig-zag.
Because we will want to know what kind of tetromino each instance is, we will need to defined an enumeration TetrominoStyle
:
public enum TetrominoStyle
{
Straight,
Block,
TShaped,
LeftZigZag,
RightZigZag,
LShaped,
ReverseLShaped
}
Tetrominos can also be rotated. Most tetrominos have four possible orientations. Take, the T-Shaped tetromino, for example:
For a another example, let's use the L-shaped tetromino:
Six of the seven tetrominos can rotate. We will therefore want another enumeration to represent the current orientation of the tetromino.
We'll name the values in this enumeration based upon the apparent orientation of the tetromino: left-to-right, up-to-down, right-to-left, and down-to-up. Note that we will have to define what each of these mean for each tetromino.
public enum TetrominoOrientation
{
UpDown,
LeftRight,
DownUp,
RightLeft
}
The Base Class
A little bit of experience with object-oriented modeling tells us that each tetromino should probably inherit from a common class. This common class will keep the common properties, such as color. Let's work out what these common properties are.
- We already know about the color, which we will implement as a CSS class.
- We also need to know the current coordinate for the center cell of the tetromino, since this cell will define the point from which the other cells are shown.
- We need the tetromino's style and current orientation.
- We need an instance of
CellCollection
from Part 2 to store the "occupied" cells. - We need a reference to the
Grid
object that this tetromino exists in.
All of that together leads to our skeleton Tetromino
class:
public class Tetromino
{
// Represents the grid on which this tetromino can move.
public Grid Grid { get; set; }
// The current orientation of this tetromino.
// Tetrominos rotate about their center.
public TetrominoOrientation Orientation { get; set; }
= TetrominoOrientation.LeftRight;
// The X-coordinate of the center piece.
public int CenterPieceRow { get; set; }
// The Y-coordinate of the center piece.
public int CenterPieceColumn { get; set; }
// The style of this tetromino, e.g. Straight, Block, T-Shaped, etc.
public virtual TetrominoStyle Style { get; }
// The CSS class that is unique to this style of tetromino.
public virtual string CssClass { get; }
// A collection of all spaces currently occupied by this tetromino.
// This collection is calculated by each style.
public virtual CellCollection CoveredCells { get; }
}
Note that the properties for Style
, CssClass
, and CoveredCells
are all marked virtual
. Each class that implements a specific tetromino will need to override those properties; we'll do that after we finish coding up the base Tetromino
class.
Constructor
When we create a new tetromino, we assume that it must exist on an instance of Grid
; to do this, we pass it to the Tetromino
object's constructor. We will also initialize it's CenterPieceRow
to the top row of the grid, and the CenterPieceColumn
property to the midpoint of the grid's width.
The resulting constructor looks like this:
public class Tetromino
{
//...Other properties and methods
public Tetromino(Grid grid)
{
Grid = grid;
CenterPieceRow = grid.Height;
CenterPieceColumn = grid.Width / 2;
}
}
We can now begin to implement the common functionality between all tetrominos.
Moving Left and Right
You may recall that in Part 2 we defined the methods GetLeftmost()
, GetRightmost()
and GetLowest()
in the CellCollection
class. This is where we put those methods to use.
A tetromino is allowed to move left or right if there are no occupied cells in the way, and the piece is not up against the sides of the game grid. Let's two methods, which check to see if the tetromino can move left or right respectively.
public class Tetromino
{
//...Other properties and methods
public bool CanMoveLeft()
{
//For each of the covered spaces,
//get the space immediately to the left
foreach (var cell in CoveredCells.GetLeftmost())
{
if (Grid.Cells.Contains(cell.Row, cell.Column - 1))
return false;
}
//If any of the covered spaces are currently in the leftmost column,
//the piece cannot move left.
if (CoveredCells.HasColumn(1))
return false;
return true;
}
public bool CanMoveRight()
{
//For each of the covered spaces,
//get the space immediately to the right
foreach (var cell in CoveredCells.GetRightmost())
{
if (Grid.Cells.Contains(cell.Row, cell.Column + 1))
return false;
}
//If any of the covered spaces are currently in the rightmost column,
//the piece cannot move right.
if (CoveredCells.HasColumn(Grid.Width))
return false;
return true;
}
}
These methods use the Tetromino
class's reference to a Grid
instance to check if that grid has occupied cells to the immediate left and right of the tetromino.
With those methods in place, our MoveLeft()
and MoveRight()
methods become very straightforward.
public class Tetromino
{
//...Other properties and methods
public void MoveLeft()
{
if (CanMoveLeft())
CenterPieceColumn--;
}
public void MoveRight()
{
if (CanMoveRight())
CenterPieceColumn++;
}
}
Moving Down
We need two more methods to check if the tetromino can move down one row, and to actually move the tetromino down.
public class Tetromino
{
//...Other properties and methods
public bool CanMoveDown()
{
//For each of the covered spaces, get the space immediately below
foreach (var coord in CoveredCells.GetLowest())
{
if (Grid.Cells.Contains(coord.Row - 1, coord.Column))
return false;
}
//If any of the covered spaces are currently in the lowest row,
//the piece cannot move down.
if (CoveredCells.HasRow(1))
return false;
return true;
}
public void MoveDown()
{
if (CanMoveDown())
CenterPieceRow--;
}
}
Rotating
We have defined the orientation of each tetromino to be represented by the TetrominoOrientation
enum. Therefore, when a tetromino is rotated, we change the Style
property to represent the new orientation.
We must also account for a potential bug: if the tetromino, once rotated, will exist outside the bounds of the play area, we must adjust it so that the entire tetromino remains in the grid.
Our method to do both of these looks like this:
public class Tetromino
{
//... Other properties and methods
// Rotates the tetromino around the center piece.
// Tetrominos always rotate clockwise.
public void Rotate()
{
switch(Orientation)
{
case TetrominoOrientation.UpDown:
Orientation = TetrominoOrientation.RightLeft;
break;
case TetrominoOrientation.RightLeft:
Orientation = TetrominoOrientation.DownUp;
break;
case TetrominoOrientation.DownUp:
Orientation = TetrominoOrientation.LeftRight;
break;
case TetrominoOrientation.LeftRight:
Orientation = TetrominoOrientation.UpDown;
break;
}
var coveredSpaces = CoveredCells;
//If the new rotation of the tetromino means it would be outside the
//play area, shift the center cell so as to
//keep the entire tetromino visible.
if(coveredSpaces.HasColumn(-1))
{
CenterPieceColumn += 2;
}
else if (coveredSpaces.HasColumn(12))
{
CenterPieceColumn -= 2;
}
else if (coveredSpaces.HasColumn(0))
{
CenterPieceColumn++;
}
else if (coveredSpaces.HasColumn(11))
{
CenterPieceColumn--;
}
}
}
Building the Individual Tetrominos
We've finished the implementation of the base Tetromino
class, and are ready to move on to building classes for each kind of tetromino.
We're not going to build every single class completely here; after doing a few, the method by which we can code them up becomes clear. Instead, we'll tackle building three of the seven tetrominos: the block, the straight, and the L-shaped.
Tetromino 1: Block
Let's start this section by building the Block tetromino.
Our Block
class is going to inherit from the base Tetromino
, and needs to override three properties:
public class Block : Tetromino
{
public Block(Grid grid) : base(grid) { }
public override TetrominoStyle Style => TetrominoStyle.Block;
public override string CssClass => "tetris-yellow-cell";
public override CellCollection CoveredCells
{
get
{
//TODO
}
}
}
The trickiest part of defining the individual tetromino classes is what to do with the CoveredCells
property. This property is intended to be populated with the cells in the Grid that are currently occupied by this tetromino.
The first thing we have to do is to establish which cell is the center cell. On the block tetromino, we'll define it as being the lower-left cell.
We now need CoveredCells
to return an instance of CellCollection
that has all the covered cells in it. We know that one of them will be the center cell, so we add a Cell
instance with the center cell's coordinates:
public class Block : Tetromino
{
//... Other properties
public override CellCollection CoveredCells
{
get
{
CellCollection cells = new CellCollection();
cells.Add(CenterPieceRow, CenterPieceColumn);
//TODO
}
}
}
From the center cell of the block, we need to occupy the following cells:
- Center Row + 1, Center Column - Upper-left cell
- Center Row, Center Column + 1 - Lower-right cell
- Center Row + 1, Center Column + 1 - Upper-right cell.
So, our implementation of CoveredCells
looks like this:
public class Block : Tetromino
{
//... Other properties
public override CellCollection CoveredCells
{
get
{
CellCollection cells = new CellCollection();
cells.Add(CenterPieceRow, CenterPieceColumn);
cells.Add(CenterPieceRow - 1, CenterPieceColumn);
cells.Add(CenterPieceRow, CenterPieceColumn + 1);
cells.Add(CenterPieceRow - 1, CenterPieceColumn + 1);
return cells;
}
}
}
Unlike all the other tetrominos, the block tetromino does not rotate. Because of this, our implementation for Block
is pretty straightforward. How would the implementation for a slightly-more-complex tetromino look?
Tetromino 2: Straight
The initial implementation of the Straight
tetromino is very similar to the Block
:
public class Straight : Tetromino
{
public Straight(Grid grid) : base(grid) { }
public override TetrominoStyle Style => TetrominoStyle.Straight;
public override string CssClass => "tetris-lightblue-cell";
public override CellCollection CoveredCells
{
get
{
CellCollection cells = new CellCollection();
cells.Add(CenterPieceRow, CenterPieceColumn);
//TODO
}
}
}
Just like with Block
, the Straight
needs to include the center piece as part of the CoveredCells
property. However, unlike Block
, an instance of Straight
can be rotated by the user. We therefore need to determine what cells are part of CoveredCells
for each possible orientation.
In the default orientation of left-to-right, the center piece of a Straight
instance is third from the left.
Consequently, the CoveredCells
implementation looks like this:
public class Straight : Tetromino
{
//... Other properties
public override CellCollection CoveredCells
{
get
{
CellCollection cells = new CellCollection();
cells.Add(CenterPieceRow, CenterPieceColumn);
if (Orientation == TetrominoOrientation.LeftRight)
{
cells.Add(CenterPieceRow, CenterPieceColumn - 1);
cells.Add(CenterPieceRow, CenterPieceColumn - 2);
cells.Add(CenterPieceRow, CenterPieceColumn + 1);
}
}
}
}
The remaining orientations (up-to-down, right-to-left, and down-to-up) make the Straight
"point" in different directions.
Each of these must be implemented as part of CoveredCells
.
public class Straight : Tetromino
{
//... Other properties
public override CellCollection CoveredCells
{
get
{
CellCollection cells = new CellCollection();
cells.Add(CenterPieceRow, CenterPieceColumn);
if (Orientation == TetrominoOrientation.LeftRight)
{
cells.Add(CenterPieceRow, CenterPieceColumn - 1);
cells.Add(CenterPieceRow, CenterPieceColumn - 2);
cells.Add(CenterPieceRow, CenterPieceColumn + 1);
}
else if (Orientation == TetrominoOrientation.DownUp)
{
cells.Add(CenterPieceRow - 1, CenterPieceColumn);
cells.Add(CenterPieceRow + 1, CenterPieceColumn);
cells.Add(CenterPieceRow + 2, CenterPieceColumn);
}
else if(Orientation == TetrominoOrientation.RightLeft)
{
cells.Add(CenterPieceRow, CenterPieceColumn - 1);
cells.Add(CenterPieceRow, CenterPieceColumn + 1);
cells.Add(CenterPieceRow, CenterPieceColumn + 2);
}
else //UpDown
{
cells.Add(CenterPieceRow - 1, CenterPieceColumn);
cells.Add(CenterPieceRow - 2, CenterPieceColumn);
cells.Add(CenterPieceRow + 1, CenterPieceColumn);
}
return cells;
}
}
}
Tetromino 3: L-Shaped
The L-Shaped tetromino has four possible orientations:
Our LShaped
class looks like this:
public class LShaped : Tetromino
{
public LShaped(Grid grid) : base(grid) { }
public override TetrominoStyle Style => TetrominoStyle.LShaped;
public override string CssClass => "tetris-orange-cell";
public override CellCollection CoveredCells
{
get
{
CellCollection cells = new CellCollection();
cells.Add(CenterPieceRow, CenterPieceColumn);
switch(Orientation)
{
case TetrominoOrientation.LeftRight:
cells.Add(CenterPieceRow, CenterPieceColumn - 1);
cells.Add(CenterPieceRow, CenterPieceColumn - 2);
cells.Add(CenterPieceRow + 1, CenterPieceColumn);
break;
case TetrominoOrientation.DownUp:
cells.Add(CenterPieceRow, CenterPieceColumn + 1);
cells.Add(CenterPieceRow + 1, CenterPieceColumn);
cells.Add(CenterPieceRow + 2, CenterPieceColumn);
break;
case TetrominoOrientation.RightLeft:
cells.Add(CenterPieceRow, CenterPieceColumn + 1);
cells.Add(CenterPieceRow, CenterPieceColumn + 2);
cells.Add(CenterPieceRow - 1, CenterPieceColumn);
break;
case TetrominoOrientation.UpDown:
cells.Add(CenterPieceRow, CenterPieceColumn - 1);
cells.Add(CenterPieceRow - 1, CenterPieceColumn);
cells.Add(CenterPieceRow - 2, CenterPieceColumn);
break;
}
return cells;
}
}
}
You can see the rest of the individual tetromino classes (Reverse L-Shaped, T-Shaped, Left Zig-Zag, and Right Zig-Zag) in the sample project on GitHub.
Summary
In this part of our series, we've created the Tetromino
class and several individual classes representing each tetromino. We also created enumerations for TetrominoStyle
and TetrominoOrientation
and a few new methods to CellCollection
. We're about halfway done with our implementation.
In the next part of this series, we'll build the blazor component for the game board, and work on the game loop. Stick around!
Happy Coding!