At this point in our series, we have the entire C# model of our Tetris game ready. What we're going to do now is create a set of Blazor components to actually display the game grid and a falling tetromino.
At the end of this post, we will be able to:
- Display the game grid.
- Click a "Start" button to begin the game.
- Have a randomly-generated tetromino "fall" down the grid.
Ready? Let's go!
The Sample Project
The BlazorGames repository over on GitHub contains all of the code used in this series! Check it out!
Previously in this Series
Goal 1: The Game Grid
Let's recall what our game grid is supposed to look like:
A standard game of Tetris has a grid that is comprised of cells. The grid is 20 rows tall and 10 columns wide.
We're going to first make a Blazor Component that represents an individual cell, then another component (which will be our main one) that will display every cell.
Cells
An individual cell on a Tetris game grid will need both a row and a column value.
Cells can be "occupied", either by the currently-falling tetromino, or by "solidified" cells that have already been placed. An occupied cell has a CSS class applied to it, depending on the color it needs to be. Our cell component, therefore, will need a reference to a Tetromino
instance and a Grid
instance.
The basic GridCell
Blazor component looks like this:
@code {
[Parameter]
public int Row { get; set; }
[Parameter]
public int Column { get; set; }
[Parameter]
public Tetromino Tetromino { get; set; }
[Parameter]
public Grid Grid { get; set; }
}
@{
//TODO - Cell CSS
}
<div id="@(Row + "-" + Column)" class="tetris-cell @extraCSS"></div>
See the @extraCSS
property? We use that to set the color of the cell, if it is occupied.
We will call Tetromino.CoveredCells
to check if the cell is currently occupied by the falling tetromino. Similarly, we will invoke one of the extension methods from Part 2, Grid.Cells.Contains()
, to determine if the cell is occupied by a solidified piece.
The code we use to set the cell's CSS class would then be:
@code {
//... Parameters
}
@{
string extraCSS = "";
if (Grid.IsStarted)
{
if (Tetromino.CoveredCells.Contains(Row, Column))
{
extraCSS = "tetris-piece " + Tetromino.CssClass;
}
if (Grid.Cells.Contains(Row, Column))
{
extraCSS = "tetris-piece " + Grid.Cells.GetCssClass(Row, Column);
}
}
}
<div id="@(Row + "-" + Column)" class="tetris-cell @extraCSS"></div>
With that, our GridCell
component is complete! Now we can start coding up the main grid component.
The Grid Component
Let's start with a simple Blazor component Tetris
which has an instance of Grid
, as well as a property for the currently-falling tetromino:
@page "/tetris"
@using BlazorGames.Models.Tetris;
@using BlazorGames.Models.Tetris.Tetrominos;
@using BlazorGames.Pages.Partials;
@using BlazorGames.Models.Tetris.Enums;
@code {
Grid grid = new Grid();
Tetromino currentTetromino;
}
<PageTitle Title="Tetris" />
<div class="row">
<div class="col">
<!--Grid goes here-->
</div>
</div>
Note: We covered using the PageTitle
component in an earlier post:
We now need some code to display each cell using the GridCell
component we wrote earlier in this post. The code is do this uses two foreach
loops, and is otherwise pretty straightfoward:
<!-- page URL, usings, and injects -->
@code { //Code implementation }
<!-- page title -->
<div class="row">
<div class="col">
<div class="tetris-container" tabindex="0">
@for (int i = grid.Height; i >= 1; i--)
{
<div class="tetris-row">
@for (int j = 1; j <= grid.Width; j++)
{
<TetrisGridCell Row="i"
Column="j"
Tetromino="currentTetromino"
Grid="grid" />
}
</div>
}
</div>
</div>
</div>
Note that we are numbering the rows from 20 at the top of the grid to 1 at the bottom of it.
When we run the app, the page now looks like this:
Progress! We've completed the first of our three goals. Now we can work on the "start game" button.
Goal 2: The "Start Game" Button
As we discussed in Part 2, the Grid
instance is what tracks the current state of the game. If the state is NotStarted
, we want to display a "Start Game" button.
That button will invoke a method called RunGame()
. We only want to run the game as long as there are no "solidified" cells in the Grid with a row higher than the grid's row count (e.g. a cell at row 21 when the grid only goes to row 20); at that point the game is over.
The markup and code that we need on the main component for the "Start Game" button is the following:
@page "tetris"
<!-- using statements -->
@code {
//... Other properties
public async Task RunGame()
{
//Start playing the game
grid.State = GameState.Playing;
//Where there is no piece with a row of 21 or greater
while(!grid.Cells.HasRow(21))
{
//TODO - Game Loop
}
//Once there is a piece with a row of 21 or greater,
//the game is over.
grid.State = GameState.GameOver;
}
}
<!---page title -->
<div class="row">
<div class="col">
@if (grid.State == GameState.NotStarted)
{
<button @onclick="RunGame"
class="btn btn-primary">Start!</button>
}
</div>
</div>
<!-- grid markup -->
Pay special attention to the //TODO - Game Loop
comment here. Any code in that while
loop will be part of the code comprising the "game loop" or the code that actually allows the game to function.
If we run the app now, we get the start button to appear:
The last thing we will do in this post is make a tetromino (the placeholder Block
from earlier) begin to fall after the user presses the start button.
The question at the moment is: how long does the piece wait until it moves down a row?
The Standard Delay
We're going to use a custom Delay()
method in this implementation which looks like this:
@page "tetris"
//...Usings and injects
@code {
//...Other properties
//The standard delay is how long the game waits
//before dropping the current piece by one row.
int standardDelay = 1000;
//This flag is set if the player "hard drops"
//a tetromino all the way to the bottom
bool skipDelay = false;
//...Other methods
//Delays the game up to the passed-in amount of milliseconds
//in 50 millisecond intervals
public async Task Delay(int millis)
{
int totalDelay = 0;
while (totalDelay < millis && !skipDelay)
{
totalDelay += 50;
await Task.Delay(50);
}
skipDelay = false;
}
}
In a game of Tetris, as your score gets higher, the "standard delay" before the falling tetromino drops another row gets shorter. It begins at 1000 milliseconds at the start of the game, and becomes 100 milliseconds shorter after the player goes up each level.
You'll see more of drops, scoring, and levels in the next post in this series. For now, just trust me that this method works as intended.
Goal 3: Implementing the Falling Tetromino
Our main Blazor component has a property for the currently-falling tetromino:
@code {
Grid grid = new Grid();
Tetromino currentTetromino;
}
When the game loop starts, we need to instantiate that tetromino. By doing so, the Tetromino
class will place the instance's center cell at the top of the Grid
.
Within the game loop, we need to keep track of whether or not the current tetromino can move down. If it can, we delay for the standard amount, and then drop the tetromino one row. If it cannot, we "solidify" the tetromino by adding its currently-occupied cells to the Grid
instance and then create a new tetromino that will fall. We also need a couple invocations of StateHasChanged()
.
Here's the code that comprises our current game loop:
@code {
//... Other properties
public async Task RunGame()
{
//...Other code
//Where there is no tetromino with a row of 21 or greater
while(!grid.Cells.HasRow(21))
{
StateHasChanged();
await RunCurrentTetromino();
//TODO - Game Loop
}
//Once there is a tetromino with a row of 21 or greater,
//the game is over.
grid.State = GameState.GameOver;
}
public async Task RunCurrentTetromino()
{
currentTetromino = new Block(grid);
//While the tetromino can still move down
while (currentTetromino.CanMoveDown())
{
//Wait for the standard delay
await Delay(standardDelay);
//Move the tetromino down one row
currentTetromino.MoveDown();
//Update the display
StateHasChanged();
}
//"Solidify" the current tetromino by
//adding its covered squares to the board's cells
grid.Cells.AddMany(currentTetromino.CoveredCells.GetAll(),
currentTetromino.CssClass);
}
}
When we run the app, we will now see that a block tetromino gets placed on the game grid, and starts "falling" automatically, when we press the Start button.
All right! We accomplished our three goals for this post!
Summary
In this part of our Tetris in Blazor series, we were able to display the game grid for a Tetris game. We also now have a start button, and most importantly, the initial tetromino can be created and will "fall" down the grid! We made a bunch of progress!
In the next part of this series, we'll make this a working Tetris game by:
- Adding keyboard inputs
- Focusing user input on the grid
- Clearing completed rows
- Calculating the score AND
- Implementing user levels
Stick around! You won't want to miss that.
Happy Coding!