Sonntag, 22. Juli 2018

Tetris made with SFML: Part IV

So here is the final part of the Tutorial, covering the Game- and Highscore-Class where everything that has been discussed in Part I, II and III finally comes together. You can find the complete code here.

Overview of the Game-Class

class Game {
friend class Grid;
public:
    Game();
    void run();
private:
    void proceed(Direction dir);
    void update(const sf::Time& dt);
    void rotate();
    void createTetromino();
    bool isValidMovement(std::array<sf::Vector2i, 4> block);
    void processEvents();
    void render();

    sf::RenderWindow                            mRenderWindow;
    sf::Texture                                 mTexture;
    sf::RectangleShape                          mSeparationLine;
    std::unique_ptr<Tetromino>                  mTetromino;
    std::unique_ptr<Tetromino>                  mPreview;
    std::unique_ptr<Grid>                       mGrid;
    Highscore                                   mHighScore;
    sf::Time                                    mElapsedTime;
    int                                         mID;
};

Lets start with the members first:
sf::RenderWindow is needed to draw all our stuff to the display.
sf::Texture will load our Blocks.png-Image as shown in Picture 1 of this tutorial in Part I.
sf::RectangleShape is just to draw a separation-line between the Grid and the space right to it where our highscore will be displayed.
mTetromino is our "active" Tetromino which can be controlled by the player and which will get passed to the Grid when it collides with something relevant (bottom or block on the Grid).
mPreview is a Tetromino displayed in the Highscore-Space right to the Grid to show the next Tetromino that will follow after the active one.
mGrid is our Grid - object we have discussed in great Detail in Part I and Part II
mHighScore is our Highscore-Class that takes care of the score-calculation and display of it.
mElapsedTime stores the elapsed time between each frame and last but not least mID stores the ID of the current "active" Tetromino and gets a new value each time a new Tetromino has to be created.
The mID is also important to make sure the previewed Tetromino gets loaded as the next active Tetromino. This will be discussed later in detail.

Game::Game() : mRenderWindow{sf::VideoMode{10 * 18 + 100, 18 * 18}, "Tetris", sf::Style::Default},
mTexture(), mSeparationLine(), mTetromino(nullptr), mPreview(nullptr), mGrid(),
mHighScore(), mElapsedTime(sf::Time::Zero), mID(getRandomNumber(7)){

    mSeparationLine.setSize(sf::Vector2f{1.f, 18.f * 18.f});
    mSeparationLine.setPosition(sf::Vector2f{10.f * 18.f, 0});
    mSeparationLine.setFillColor(sf::Color::Red);
    if(!mTexture.loadFromFile("Blocks.png")){
        std::cout << "Game::Game() - could not load mTexture\n";
    };
    mGrid = std::make_unique<Grid>(sf::Vector2i{10, 18}, *this);
    createTetromino();
}

In the Game - constructor we first initialize all members in the initialization-list with their default values.
Next we set up the sf::RectangleShape mSeparationLine that will draw a line between the Grid and the space right of it which will be used to display the score and the next Tetromino.
Next we load the Blocks.png-Image in our sf::Texture - variable mTexture.
We create a new Grid - object using std::make_unique, passing it the size we want it to have and a pointer to the Game - class, so it can invoke its methods and members.
The last thing that needs to be done in the constructor is is to create the first Tetromino that will be controlled by the player, so the createTetromino() - method is invoked that takes care of creating new Tetrominos.

void Game::run() {
    sf::Clock clock;
    sf::Time deltaTime{sf::Time::Zero};
    while(mRenderWindow.isOpen()){
        sf::Time trigger{sf::seconds(85.f / (85.f + (mHighScore.getLevel() * (mHighScore.getLevel() * 5.f))))};
        deltaTime = clock.restart();
        mElapsedTime += deltaTime;
        processEvents();
        update(deltaTime);
        if(mElapsedTime > trigger) {
            mElapsedTime = sf::Time::Zero;
            proceed(Direction::Down);
        }
        render();
    }
}

Not much happens in the run()-method considering that it includes the main-game-loop

while(mRenderWindow.isOpen()){

First we create a local sf::Clock - object called clock that will be used to track the time that pass by each iteration of the main-loop.
clock.restart() returns a sf::Time - object that holds the amount of time passed since the object was created or since the method was last time invoked.
Because computers nowadays are blazingly fast the returned value should be pretty tiny. We add it to the mElapsedTime variable.
This way mElapsedTime rises in normal time, for example if in realtime 3,4 seconds pass, its value would be something like "3.46742". However we have a trigger variable that determines how long the elapsed time between the next "tick" has to be:

sf::Time trigger{sf::seconds(85.f / (85.f + (mHighScore.getLevel() * (mHighScore.getLevel() * 5.f))))};

The higher the current Level is, which is determined by how many lines have been cleared by the player, the smaller the trigger-value will be.
The smaller the trigger - value is, the less time has to pass until mElapsedTime surpasses the trigger:

if(mElapsedTime > trigger) {
    mElapsedTime = sf::Time::Zero;
    proceed(Direction::Down);
}

once the mElapsedTime surpasses the trigger, the Game is allowed to proceed and mElapsedTime gets reset so everything can start over.
This is how the typical "step by step" flow of Tetris is established - meaning the Tetromino-movement is not fluid but stepwise. This, however, only applies for the not player-related Tetromino-Movement. The rest of the game will be updated every iteration of the game-loop and not only when the mElapsedTime surpasses the trigger. This especially holds true for the processEvents() - method, that will poll the player-input as i will discuss further below.

void Game::processEvents() {
    sf::Event e;
    while (mRenderWindow.pollEvent(e)) {
        if (e.type == sf::Event::Closed) mRenderWindow.close();
        else if (e.type == sf::Event::KeyPressed) {
            if (e.key.code == sf::Keyboard::S) {
                proceed(Direction::SoftDown);
            } else if (e.key.code == sf::Keyboard::D) {
                proceed(Direction::Right);
            } else if (e.key.code == sf::Keyboard::A) {
                proceed(Direction::Left);
            } else if (e.key.code == sf::Keyboard::Space) {
                rotate();
            } else if (e.key.code == sf::Keyboard::P) {
                mGrid->printGrid();
            }else if (e.key.code == sf::Keyboard::I) {
                mHighScore.addClearedLines(10);
            }
        }
    }
}

here we just receive the player-input and invoke all appropriate methods to process it.
Notice that when the player presses Down (Key S) the proceed(...) - method gets Direction::SoftDown passed as argument and not Direction::Down. This is important so the proceed(...) - method can distinct if the movement down was induced by the player

(Direction::SoftDown) or "gravity" (Direction::Down). If the player moved the active Tetromino down, a little bonus score is to be added to the Highscore-object since the player is awarded to push the Tetromino down. Notice also that processEvents() is not part of the trigger-controlled stepping in the main game-loop but is polled every iteration. This means, that the player-input can be updated much faster than the trigger-controlled stepping updates that affect the descent of each active Tetromino. In other words: We have decoupled player-input that can happen multiple times per second, while the gravity-induced descend of the Tetromino will stay dependend of the actual size of the trigger.

void Game::update(const sf::Time &dt) {
    mGrid->update(dt);
    mHighScore.update(dt);
    if(!mTetromino) {
        if(mGrid->isToRemoveBlocks()){
            return;
        }
        createTetromino();
    }
}

The Game::update() - method is (also) not affected by the relationship between mElapsedTime and the trigger: It gets updated as fast as possible while mRenderWindow is open.
Because the Grid- and Highscore-update() - methods get invoked here, this also applies for them. Further we have to check, if an active Tetromino is arround. If this is not the case, we have to check if an "blinking"-animation is still ongoing. While the full lines are blinking, we do not want to create a new Tetromino. So if the Grid blinks, which is indicated by Grid's isToRemoveBlocks(), we do nothing and return. Otherwise we want to create a new Tetromino invoking createTetromino().

void Game::rotate() {
    if(!mTetromino) return;
    mTetromino->rotate();
    if(!isValidMovement(mTetromino->getBlockPositions())){
        mTetromino->revertState();
    }
}

This method gets invoked when the player press a specific key for rotating the active Tetromino, which is polled by the processEvents() - method as described above.
If no active Tetromino exists, there is no active Tetromino to rotate, so we return.
Otherwise we rotate it.
Then we check if the rotation was actually allowed. If not, we just revert the rotation to the state before the rotation, so as result the Tetromino in the end did not rotate.

void Game::render() {
    mRenderWindow.clear(sf::Color::Blue);
    mHighScore.draw(mRenderWindow);
    mGrid->draw(mRenderWindow);
    if(mTetromino) mRenderWindow.draw(*mTetromino);

    mRenderWindow.draw(*mPreview);
    mRenderWindow.draw(mSeparationLine);
    mRenderWindow.display();
}

Here we just use our mRenderWindow - object to draw everything on the screen. There's not much to see here.

void Game::proceed(Direction dir) {
    if(!mTetromino) return;

    if(isValidMovement(mTetromino->getFutureBlockPositions(dir))){
        mTetromino->move(dir);
        if(dir == Direction::SoftDown) mHighScore.addSoftScore(1);
    }
    else{
        if(dir == Direction::Down || dir == Direction::SoftDown) {
            int id = mTetromino->getID();
            mGrid->addBlock(id, mTetromino->getBlockPositions());
            mTetromino.reset(nullptr);
            mHighScore.sumSoftScore();
        }
    }
}

we have to check if the future Position that is created by the passed argument dir is actually valid.

if(isValidMovement(mTetromino->getFutureBlockPositions(dir))){

If the movement is valid, we can move our Tetromino further in the direction that we received as parameter dir.

if(dir == Direction::SoftDown) mHighScore.addSoftScore(1);

While we are at it, we should also inform the Highscore-Class that a small bonus is to be added when the direction downward is caused by the player himself (indicated by Direction::SoftDown).

else{
    if(dir == Direction::Down || dir == Direction::SoftDown) {
        int id = mTetromino->getID();
        mGrid->addBlock(id, mTetromino->getBlockPositions());
        mTetromino.reset(nullptr);
        mHighScore.sumSoftScore();
    }

the else-statement handles the condition that a valid movement is not possible because we detected some form of collision between the future position of the Tetromino respectively its block-positions and some actual blocks on the Grid.
If the collision appears while the Direction of the Tetromino is Direction::Down (gravity-induced) or Direction::SoftDown (player-induced) there is only one conclusion: The "anticipated collison" we detected using

if(isValidMovement(mTetromino->getFutureBlockPositions(dir))){

must have occured below the active Tetromino (because it was moving down and we have been checking the collision supposing a step downwardbeing made). Hitting something below it, is the only condition where the Tetromino has to be passed to the Grid, so its block-positions get laid out on the Grid.
This is done by

mGrid->addBlock(id, mTetromino->getBlockPositions());

next we destroy the active Tetromino by resetting the smartpointer that owns the Tetromino-object. Furthermore we pass to the reset-method of the std::unique_ptr a nullptr, so after the object is destroyed the smartpointer is set to nullptr. This allows us safe checks like

if(mTetromino)

because the smartpointer is not dangling but set to a concrete object or nullptr.

bool Game::isValidMovement(std::array<sf::Vector2i, 4> block) {
    for(int i = 0; i < 4; ++i){
        if(block[i].x < 0 || block[i].x > 9 || block[i].y > 17){
            return false;
        }
    }
    if(mGrid->isOccupied(block)) return false;
    return true;
}

isValidMovement(...) checks for collisions of its parameter (being actually 4 block-positons) with other blocks already stored in the Grid.
Before we check against the Grid we first have to check if there is a collision with the walls of the Grid. For this we iterate through every passed block in the for-loop.
If one of the passed block-positions is outside the Grid, the planned movement is not valid, so we return false.
Otherwise we have to check further: Is one of the 4 block-positions already occupied in the grid-field? This is checked by

if(mGrid->isOccupied(block)) return false;

this method is discussed in detail in Part II of this tutorial.

void Game::createTetromino() {
   mTetromino.reset(new Tetromino{mTexture, mID});
   // create new game if necessary
   if(mGrid->isOccupied(mTetromino->getBlockPositions())){
        mGrid->clean();
        mHighScore.reset();
    }
    mID = getRandomNumber(7);
    mPreview.reset(new Tetromino{mTexture, mID});
    mPreview->setPosition(sf::Vector2i{11, 12});
}

And here we already reach the last Game-method that has not been covered yet.
createTetromino() takes care of creating a new Tetromino. We can use the reset(...) - method of the smartpointer to create a new Tetromino-Object while resetting the current one (which should have been down before anyway (see Game::proceed(...) - method for details).

if(mGrid->isOccupied(mTetromino->getBlockPositions())){
     mGrid->clean();
     mHighScore.reset();
}

This if-statement just checks if we want to create a new game. When do we need a new game? When the new Tetromino - which is placed always on top of the Grid, is created on an occupied Field, which only happens when you have piled up to many blocks on the Grid. So if you cannot create a new Tetromino without colliding, its game-over. For a new game the only thing that is needed to be done is resetting the Grid and HighStore.
Next we assign the mID a random number ranging from 0 to 7. For this we use the function getRandomNumber(int). I'll describe the function in a second, lets first finish the createTetromino() - method.
the newly assigned random value stored in mID stores two purposes: First its used to create the future Tetromino that will be spawned after the current one is passed to the Grid and gets destroyed. So we pass mID to the constructor of the newly created mPreview-Tetromino. The next time createTetromino() gets invoked the next active mTetromino will get the same - unchanged - mID as the mPreview-Tetromino before. First after we assign the mID-value to the mTetromino, mID gets a new random number assigned.
Because mPreview gets newly instantiated and the Tetromino - constructor sets automatically to a specific position, we have to change it manually for mPreview to get it to the proper location right to the Grid.

We put the function in the Utils.hpp - Header.

#ifndef UTILS_HPP
#define UTILS_HPP

#include <random>

int getRandomNumber(int max);
int getRandomNumber(int min, int max);

#endif //UTILS_HPP

In the cpp-File we create a variable of type std::default_random_engine named engine and pass it the time passed since starting the PC, so it gets a unique number as a seed.

#include "Utils.hpp"

#include <chrono>
std::default_random_engine engine{static_cast<unsigned int>(
        std::chrono::system_clock::now().time_since_epoch().count())};


int getRandomNumber(int max) {
    std::uniform_int_distribution<int> int_distribution(max);
    return int_distribution(engine);
}

int getRandomNumber(int min, int max) {
    std::uniform_int_distribution<int> int_distribution(min, max);
    return int_distribution(engine);
}

in the getRandomNumber() - functions we create a templated function-object of type std::uniform_int_distribution named int_distribution and give it a max respectively a min and max range. Passing our engine to the function-object returns a random value of the given range using the passed engine. Frankly I never found using classes from the <random> header very intuitive to use, but it is how it is. So lets move on to the final Class: The Highscore-Class.

Overview of the Highscore-Class


class Highscore {
public:
    Highscore();
    Highscore(const Highscore& other) = delete;
    Highscore& operator = (const Highscore& other) = delete;
    void draw(sf::RenderWindow& window);
    void reset();
    void addSoftScore(int score);
    void sumSoftScore();
    void addClearedLines(int num);
    void update(const sf::Time& dt);
    int getLevel() const;
private:
    sf::Font            mFont;
    sf::Text            mLevelText;
    sf::Text            mScoreText;
    sf::Text            mClearedLinesText;
    int                 mScore;
    int                 mLoadSoftScore;
    int                 mLinesCleared;


};

We delete its copy- and assignment constructor because there will only be one Highscore - object and thus no copying is needed.

Highscore::Highscore()
: mFont(), mLevelText(), mScoreText(), mClearedLinesText(), mScore(0), mLoadSoftScore(0),
mLinesCleared(0){
    mFont.loadFromFile("Dong.ttf");
    mScoreText.setFont(mFont);
    mScoreText.setCharacterSize(15);

    mLevelText.setFont(mFont);
    mLevelText.setCharacterSize(15);
    mScoreText.setPosition(sf::Vector2f{10 * 18 + 3, 50.f});
    mLevelText.setPosition(sf::Vector2f{10 * 18 + 3, 100.f});

    mClearedLinesText.setFont(mFont);
    mClearedLinesText.setCharacterSize(15);
    mClearedLinesText.setPosition(10*18 + 3, 150.f);
}

Once again we use a initialization-list to initialize all members to its default values. Next we load the Font and set the mScoreText, mLevelText and mCleardLinesText to their appropriate positions right to the Grid.

void Highscore::update(const sf::Time &dt) {
    mLevelText.setString(std::string{"Level:\n" + std::to_string(mLinesCleared / 10)});
    mScoreText.setString(std::string{"Score:\n" + std::to_string(mScore)});
    mClearedLinesText.setString(std::string{"Lines:\n" + std::to_string(mLinesCleared)});
}

update() has to update the sf::Text - objects to the current state of the variables holding the stats of the current game.

void Highscore::reset() {
    mLinesCleared = 0;
    mScore = 0;
}

void Highscore::addSoftScore(int score) {
    mLoadSoftScore += score;
}

void Highscore::sumSoftScore() {
    mScore += mLoadSoftScore;
    mLoadSoftScore = 0;
}

int Highscore::getLevel() const {
    return mLinesCleared / 10;
}

addSoftScore() just piles the score up by adding it to the mLoadSoftScore. This member stores the little bonus that the player get for moving the active Tetromino down pro-actively. That added "extra bonus" stored in mLoadSoftScore has to be added to the "actual" score of the player saved in mScore. This is done by the sumSoftScore() - method. 

getLevel() returns the current Level that is dependend of how many lines have been cleared by the player. Every 10 cleared blocks let the level increase. The stepwise gamespeed is dependend by the current level. A higher level leads to a faster game-tick. This is described in the Game::run() - method in detail.

void Highscore::addClearedLines(int num) {
    mLinesCleared += num;
    int level = mLinesCleared / 10;
    switch (num){
        case (1): {
            mScore += 40 * (level + 1);
            break;
        }
        case (2): {
            mScore += 100 * (level + 1);
            break;
        }
        case (3): {
            mScore += 300 * (level + 1);
            break;
        }
        case (4): {
            mScore += 1200 * (level + 1);
            break;
        }
    }
}

This method converts the formula below which you can examine here in detail:

Picture 16: Original Nintendo Scoring System

So thats basically it. I hope you enjoyed following along as i did writing this implementation of Tetris for the most part. If you have suggestions to improve the general approach or specific items i discussed in this tutorial feel free to let me know in the comments. Writing Tetris is a great journey, because it still is a wonderful classic that has outlasted time well.

1 Kommentar: