
The Tetris window
In this application, we do not use the StandardDocument
framework from the Chapter 2, Hello, Small World!. Instead, the TetrisWindow
class extends the Small Windows root class Window
directly. The reason is simply that we do not need the functionality of the StandardDocument
framework or its base class Document
. We do not use menus or accelerators, and we do not save or load files:
TetrisWindow.h
class TetrisWindow : public Window { public: TetrisWindow(WindowShow windowShow); ~TetrisWindow();
In this application, we ignore the mouse. Instead, we look into keyboard handling. The OnKeyDown
method is called when the user presses or releases a key:
bool OnKeyDown(WORD key, bool shiftPressed, bool controlPressed);
Similar to the circle application, the OnDraw
method is called every time the window's client area needs to be redrawn:
void OnDraw(Graphics& graphics, DrawMode drawMode) const;
The OnGainFocus
and OnLoseFocus
methods are called when the window gains or loses input focus, respectively. When the window loses input focus, it will not receive any keyboard input and the timer is turned off, preventing the falling figure from moving:
void OnGainFocus(); void OnLoseFocus();
The OnTimer
method is called every second the window has focus. It tries to move the falling figure one step downward. It calls the NewFigure
method if it fails to move the figure downward. The NewFigure
method tries to introduce a new figure on the game board. If that fails, the GameOver
method is called, which asks the user if they want a new game. The NewGame
method is called if the user wants a new game. If the user does not want a new game, it exits the application:
void OnTimer(int timerId); void EndOfFigure(); void GameOver(); void NewGame();
the DeleteFullRows
examines each row by calling the IsRowFull
method and calls the FlashRow
and DeleteRow
methods for each full row:
void DeleteFullRows(); bool IsRowFull(int row); void FlashRow(int row); void DeleteRow(int markedRow);
The TryClose
method is called if the user tries to close the window by clicking on the cross in the top-right corner of the window. It displays a message box that asks the user if they really want to quit:
bool TryClose();
The gameGrid
field holds the grid on which the figures are displayed (see the next section). The falling figure (fallingFigure
) is falling down on the grid, and the next figure to fall down (nextFigure
) is displayed in the top-right corner. Each time the player fills a row, the score (currScore
) is increased. The timer identity (TimerId
) is needed to keep track of the timer and is given the arbitrary value of 1000
. Finally, the figure list (figureList
) will be filled with seven figures, one of each color. Each time a new figure is needed, a randomly chosen figure from the list will be chosen and copied:
private: GameGrid gameGrid; TetrisFigure fallingFigure, nextFigure; int currScore = 0; bool timerActive = true, inverse = false; static const int TimerId = 1000; vector<TetrisFigure> figureList; };
The PreviewCoordinate
parameter in the Window
constructor call indicates that the window's size is fixed, and the second parameter indicates that the size is 100 * 100 units. This means that unlike the circle application, the size of figures and game boards will change when the user changes the window's size:
TetrisWindow.cpp
#include "..\\SmallWindows\\SmallWindows.h" #include "GameGrid.h" #include "TetrisFigure.h" #include "RedFigure.h" #include "BrownFigure.h" #include "TurquoiseFigure.h" #include "GreenFigure.h" #include "YellowFigure.h" #include "BlueFigure.h" #include "PurpleFigure.h" #include "TetrisWindow.h" TetrisWindow::TetrisWindow(WindowShow windowShow) :Window(PreviewCoordinate, Rect(0, 0, 100, 100), nullptr, OverlappedWindow, NoStyle, Normal),
The upper 20 percent of the client area is reserved for the score and the next figure. The game grid covers the lower 80 percent of the client area (from height unit 20 to 100):
gameGrid(Rect(0, 20, 100, 100)) {
Since we extend the Window
class, we need to set the window header manually:
SetHeader(TEXT("Tetris"));
The timer interval is set to 1000
milliseconds, which means that OnTimer
will be called every second. The random generator is initialized by calling the C standard functions srand
and time
:
SetTimer(TimerId, 1000); srand((unsigned int) time(nullptr));
The figure list is initialized with one figure of each color; the falling and next figure are randomly chosen from that list. One of the figures in the list will be copied every time we need a new figure:
figureList.push_back(RedFigure(this, &gameGrid)); figureList.push_back(BrownFigure(this, &gameGrid)); figureList.push_back(TurquoiseFigure(this, &gameGrid)); figureList.push_back(GreenFigure(this, &gameGrid)); figureList.push_back(YellowFigure(this, &gameGrid)); figureList.push_back(BlueFigure(this, &gameGrid)); figureList.push_back(PurpleFigure(this, &gameGrid)); fallingFigure = figureList[rand() % figureList.size()]; nextFigure = figureList[rand() % figureList.size()]; }
Strictly speaking, it is not necessary to drop the timer when closing the Tetris window. The destructor is included only for the sake of completeness:
TetrisWindow::~TetrisWindow() { DropTimer(TimerId); }
Keyboard input
The OnKeyDown
method overrides the method in the Window
class and is called each time the user presses a key. We try to move the falling figure in accordance with the key pressed. We do not care whether the user has pressed the Shift or Ctrl key:
bool TetrisWindow::OnKeyDown(WORD key, bool /* shiftPressed */, bool /* controlPressed */) { switch (key) { case KeyLeft: fallingFigure.TryMoveLeft(); break; case KeyRight: fallingFigure.TryMoveRight(); break; case KeyUp: fallingFigure.TryRotateAnticlockwise(); break; case KeyDown: fallingFigure.TryRotateAnticlockwise(); break;
When the user presses the Space key, the falling figure falls with visible speed to create the illusion of falling. We try to move the falling figure one step down every 10 milliseconds by calling the Win32 API function Sleep
. The TryMoveDown
method returns false
when it is no longer possible to move the figure downward:
case KeySpace: while (fallingFigure.TryMoveDown()) { ::Sleep(10); } break; } return true; }
Drawing
The OnDraw
method starts by drawing the game grid and two lines dividing the client area into three parts. The top-left corner displays the current score, the top-right corner displays the next figure, and the lower part displays the actual game grid:
void TetrisWindow::OnDraw(Graphics& graphics, DrawMode /* drawMode */) const { gameGrid.DrawGameGrid(graphics, inverse); graphics.FillRectangle(Rect(Point(0, 0), Point(100,20)), White, White); graphics.DrawLine(Point(40, 0), Point(40, 20), Black); graphics.DrawLine(Point(0, 20), Point(100, 20), Black);
Note that we add an offset when drawing the next figure in order to move from the game grid to the top-right corner. The value 25
moves the figure from the middle of the grid to the middle of its right half, and the value -18
moves from the grid up to the area preceding the grid:
fallingFigure.DrawFigure(graphics); nextFigure.DrawFigure(graphics, Size(25, -18));
The score font is set to Times New Roman
, size 10
. Here, the size does not refer to typographical points, but to logical units. Since the call to the Window
constructor states we gave the PreviewCoordinate
coordinate system and the size 100 * 100, the height of the text will be 10 units, which is a tenth of the text client area's height. It is also half the height of the part of the client area where the score is written:
Font scoreFont(TEXT("Times New Roman"), 10);
The final false
parameter in the call to the DrawText
method indicates that the size of the text won't be recalculated. In the next chapters, we will display text that maintains the same size, regardless of the window size and the screen resolution. In this chapter, however, the size of the text will be changed when the user changes the size of window:
graphics.DrawText(Rect(0, 0, 40, 20), to_String(currScore), scoreFont, Black, White, false); }
Input focus
The OnGainFocus
and OnLoseFocus
methods start and stop the timer, respectively, so that the falling figure does not fall down when the window is out of focus:
void TetrisWindow::OnGainFocus() { SetTimer(TimerId, 1000); } void TetrisWindow::OnLoseFocus() { DropTimer(TimerId); }
The timer
The timer is active when it has the input focus. When active, the TryMoveDown
method will be called every time the OnTimer
method is called (once every second). When the figure cannot fall down any more (the TryMoveDown
method returns false
), the EndOfFigure
method is called:
void TetrisWindow::OnTimer(int /* timerId */) { if (timerActive) { if (!fallingFigure.TryMoveDown()) { EndOfFigure(); } } }
New figures
When it is not possible for the falling figure to move downward, the OnTimer
method calls the NewFigure
method. First, we need to store the falling figure to the game grid by calling the AddToGrid
method. Then, we let the next figure become the new falling figure and we choose by random the new next figure from the figure list. We invalidate the area of the new falling figure and the area of the top-right corner where the next figure is drawn:
void TetrisWindow::NewFigure() { fallingFigure.AddToGrid(); fallingFigure = nextFigure; fallingFigure.InvalidateFigure(); nextFigure = figureList[rand() % figureList.size()]; Rect nextArea(40, 0, 100, 20); Invalidate(nextArea); UpdateWindow();
We delete the possible full rows and update the window:
DeleteFullRows(); UpdateWindow();
If the new falling figure is not valid from the very beginning, the game is over and GameOver
is called:
if (!fallingFigure.IsFigureValid()) { GameOver(); } }
Game over
The GameOver
method presents the score and lets the user decide whether they want a new game. If they want a new game, it is initialized by the NewGame
call. If the user does not want a new game, the call to the Win32 API function PostQuitMessage
terminates the execution of the application.
Note that we call another version of the Invalidate
method, without parameters. It invalidates the whole client area:
void TetrisWindow::GameOver() { Invalidate(); UpdateWindow();
The timer is inactive while the message is displayed:
timerActive = false; String message = TEXT("Game Over.\nYou scored ") + to_String(currScore) + TEXT(" points.\nAnother game?"); if (MessageBox(message, TEXT("Tetris"), YesNo, Question)==Yes) { NewGame(); } else { ::PostQuitMessage(0); } }
New game
The NewGame
method initializes the randomly chosen new falling and next figures, resets the score, and clears the game grid before activating the timer, as well as invalidates and updates the window, which makes the new falling figure starting to fall and the new game to begin:
void TetrisWindow::NewGame() { fallingFigure = figureList[rand() % figureList.size()]; nextFigure = figureList[rand() % figureList.size()]; currScore = 0; gameGrid.ClearGameGrid(); timerActive = true; Invalidate(); UpdateWindow(); }
Deleting and flashing rows
When deleting full rows, we loop through the rows, flashing and removing each full row. We increase the score and update the area of the row. Note that the rows start at the top of the grid. This means that we have to loop from the highest row to the lowest row in order to delete the row in the right order.
Note that if the row becomes flashed and deleted, we do not update the row
variable since the deleted row will be replaced by the row above, which also needs to be examined:
void TetrisWindow::DeleteFullRows() { int row = Rows - 1; while (row >= 0) { if (IsRowFull(row)) { FlashRow(row); DeleteRow(row); ++currScore; Rect scoreArea(0, 0, 40, 20); Invalidate(scoreArea); UpdateWindow(); } else { --row; } } }
A row is considered full if it does not contain a white square:
bool TetrisWindow::IsRowFull(int row) { for (int col = 0; col < Cols; ++col) { if (gameGrid[row][col] == White) { return false; } } return true; }
The flash effect is executed by redrawing the row in normal and inversed color (the inverse
method is set) three times with an interval of 50 milliseconds. While doing this, it is especially important that we only invalidate the area of the chosen row. Otherwise, the whole window client area will be flashed:
void TetrisWindow::FlashRow(int row) { Rect gridArea = gameGrid.GridArea(); int colWidth = gridArea.Width() / Cols, rowHeight = gridArea.Height() / Rows; Rect rowArea(0, row * rowHeight, Cols * colWidth, (row + 1) * rowHeight); for (int count = 0; count < 3; ++count) { inverse = true; Invalidate(rowArea + gridArea.Top()Left()); UpdateWindow(); ::Sleep(50); inverse = false; Invalidate(rowArea + gridArea.Top()Left()); UpdateWindow(); ::Sleep(50); } }
When deleting a row, we do not really delete it. Instead, we move each row above the deleted row one step downward and fill the top row with white squares. A complication is that we count rows from the top. This makes the lowest row on the screen the row with the highest index. This gives the appearance that we start from the bottom and remove every full row until we reach the top:
void TetrisWindow::DeleteRow(int markedRow) { for (int row = markedRow; row > 0; --row) { for (int col = 0; col < Cols; ++col) { gameGrid[row][col] = gameGrid[row - 1][col]; } } for (int col = 0; col < Cols; ++col) { gameGrid[0][col] = White; } Invalidate(gameGrid.GridArea()); Invalidate(g); UpdateWindow(); }
Closing the window
Finally, when the user wants to close the window by clicking in the cross on the top-right corner, we need to confirm that they really want to quit. If the TryClose
method returns true
, the window is closed:
bool TetrisWindow::TryClose() { timerActive = false; if (MessageBox(TEXT("Quit?"), TEXT("Tetris"), YesNo, Question) == Yes) { return true; } timerActive = true; return false; }