Freitag, 20. Juli 2018

Tetris made with SFML: Part II

In Part I of the Tutorial i described the overall "plan of approach", talked about the Field and FieldInfo-Structs and described the Grid-class including its members. Now it's time to take a look at the implementation of the Grid-Class-methods.

Grid::Grid(sf::Vector2i size, Game& game)
: mGame(game), mFields(), mFieldInfos(), mSize(size),
  mYCleaned(), mElapsedTime(0.f), mToRemoveBlocks(false){
    for(int x = 0; x < size.x; ++x){
        for(int y = 0; y < size.y; ++y){
            mFields[convert2D_to_1D(x,y)] = std::make_unique<Field>();
        }
    }
    for(int id = 0; id < 7; ++id){
        mFieldInfos[id] = std::make_unique<FieldInfo>(mGame.mTexture, id);
    }
}

The constructor of the Grid-Class takes as first parameter the size we want the Grid to have (10 x 18), represented as a sf::Vector2i vector. The second parameter takes in a reference to the Game-Class. The Grid-Class object itsself will be created in the Game-Class-constructor.
In most cases the Game-Class will query the state of the Grid-object. However, it may be handy to give the Grid the ability to pro-actively send data to the Game-Class and invoke some of its methods or members. For example, the Grid-Class will recognize when lines in the grid are cleared (markedLinesForRemoval() will take care of that). When this happens, it has to tell the Highscore-Class that lines have been cleared so the highscore can be recalculated. Because the Highscore-Class will reside in the Game-Class, it is handy to have a Game& mGame reference in the Grid-Class to be able to pass data to the Highscore-Class.

After initializing all members via initialization-list, you have to iterate through two for-loops (one for the x-axis, the other for the y-axis) to initialize all Fields.

Next we have to create the 7 FieldInfo-Objects for each Block-Color from the Image. Each FieldInfo-object gets an ID from 0 to 6 so every object will have a different colored “Block” displayed when drawing its mSprite-member. We also use the same ID as their std::map-Key. This will make it easy
to find the right FieldInfo based on the ID of the Tetromino.

Grid(const Grid& other) = delete;
Grid& operator= (const Grid& other) = delete;

The copy-and assignment-constructors of the Grid-Class are deleted because there is no need to have multiple Grids around and pass/copy their content to each other.

void Grid::update(const sf::Time &dt) {
    markLinesForRemoval();
    if(mToRemoveBlocks){
        mElapsedTime += dt.asSeconds();
        blink();
        if(mElapsedTime > 0.6f){
            mElapsedTime = 0.f;
            cleanLines();
        }
    }
}

The Grid-Class will have to keep track of the elapsed time in some form. For example, if we want the cleared lines to blink when the lines are cleared and before the lines finally vanish, we need to measure the time passed and invoke further methods depending on how much time has passed. All this is done in the update() - method. It will be invoked by the Game-Class. The update() - method has a const sf::Time& reference parameter called “dt”. This parameter stores the duration of each frame of the main Game-Loop. If you sum this value up in a variable (here its done with mElapsedTime), you can keep track of the total elapsed Time.
markLinesForRemoval() has to be checked every iteration of the game.
If markLinesForRemoval() recognizes full lines, it will set the flag mToRemoveBlocks to true.
In this case the update() - method begins counting the elapsed time.
While the elapsed Time is smaller than 0.6 seconds, the full lines will blink (blink() takes care of that).
After that, the lines get finally removed in cleanLines(), mElapsedTime gets reset and mToRemoveBlocks gets set back to false. So everything can start over if markLinesForRemoval() tracks another full line.

void Grid::clean() {
    for(int x = 0; x < mSize.x; ++x){
        for(int y = 0; y < mSize.y; ++y) {
            auto field = getField(x,y);
            field->mOccupied = false;
            field->mVisible = true;
            field->mInfo = nullptr;
        }
    }
}

Invoking this method only makes sense when the player is “game over” or if he wants to start over.
Basically every Field is being set to its Default-State (being visible, not occupied and not pointing to any FieldInfo-Object).

void Grid::addBlock(int id, std::array<sf::Vector2i, 4> block) {
    for (int i = 0; i < 4; ++i) {
        auto field = getField(block[i].x, block[i].y);
        field->mOccupied = true;
        field->mInfo = mFieldInfos[id].get();
    }
}

The addBlock() - method gets invoked by the Game-Class every time it recognizes that a Tetromino cannot be moved down any further. If the Tetromino is blocked by the bottom of the Grid or another Tetromino, the Grid gets the Tetrominos Block-Positions passed via the addBlock(…)-method so it can “occupy” all Fields that matches the position of the Tetromino respectively each of its blocks. The id-variable is needed to assign the FieldInfo-Pointer mInfo of the Field to the appropriate FieldInfo-Object. This ensures that each occupied Field points to the corresponding sf::Sprite that displays the correct colored “block”.

bool Grid::isOccupied(std::array<sf::Vector2i, 4> block) {
    for(int i = 0; i < 4; ++i) {
        auto  field = getField(block[i].x, block[i].y);
        if (field->mOccupied) {
            return true;
        }
    }
    return false;
}

Each Tetromino occupies at least 4 positions on the Grid because it consists of 4 blocks. 
This method takes in an array of 4 positions (stored as sf::Vector2i) and tells you, if one of the 4 Fields in the Grid, you plan to put the 4 positions of your block on, is already occupied. If this applies, you cannot place the block anyway, so when only one of the 4 Grid-Fields is occupied, it is handled as the whole desired Grid-Space is completely occupied, thus the method returns true; 
This method gets invoked by the Game-Class which takes care of the overall Game-Logic. When a new Tetromino has to be created, the Game-Class checks if it can be created at a pre-defined starting position. If the starting position is already occupied, its “game over”. This can be checked with the described isOccupied(…) – method.

void Grid::draw(sf::RenderWindow &window) {
    for(int x = 0; x < mSize.x; ++x){
        for(int y = 0; y < mSize.y; ++y){

            auto field = getField(x,y);
            //if field has not been occupied yet, mInfo would be assigned to nullptr
            if(field->mOccupied && field->mVisible){
                field->mInfo->mSprite.setPosition(x * 18.f, y * 18.f);
                window.draw(field->mInfo->mSprite);
            }
        }
    }
}

This method takes care of the drawing of the Grid and all of its Fields.
We need two for-loops to traverse through each Field in the Grid.
We retrieve each stored Field with the getField(…) - method, passing it the current position indicated by our x and y values, so every Field gets drawn if it is occupied and visible. If it is not visible, we don’t need to draw it anyway.
But before drawing we also check if it is occupied. This is because field->mInfo-Pointer only gets assigned to a FieldInfo-Object when we occupy the Field. If it has not been occupied and we would try to set the Position of the FieldInfo-Sprite(“currentfield”->mInfo->mSprite.setPosition(…), the application would crash because we’d try to dereference a Pointer (mInfo) which would still be assigned to nullptr. That’s the reason behind the additional "mOccupied-Check" in the second if-statement.

inline bool isToRemoveBlocks() const { return mToRemoveBlocks;}

this method could also be called “bool Grid::isblinking() const”. Its purpose is to indicate if the Grid is in the process of removing full lines. When a line is full, it won’t be removed instantly. It is supposed to “blink” for a small amount of time before actually being removed. While blinking, you don’t want a new Tetromino to be created. So the Game-Class polls the Grid-Object if isToRemoveBlocks() returns true. While that’s the case, it won’t create a new Tetromino.

Field* Grid::getField(int x, int y) {
    return mFields[convert2D_to_1D(x,y)].get();
}

Field* getField(int x, int y) returns a Pointer to an actual Field that is addressed by its x and y coordinate. After all, the Grid is just a “2D Field” (actually its not, but this will be covered when discussing the convert2D_to_1D(…) - method).

Now we come to the private methods:

int Grid::convert2D_to_1D(int x, int y) {
    return y * mSize.x + x;
}

convert2D_to_1D(...) just converts a 2D coordinate to a 1D coordinate. This internal method is important, because we are going to store the Grid-Fields in a std::map and their 2D coordinates are going to be used as the “Key” of std::map’s Key-Value-Pair that will be stored as unsigned int (which are obviously not 2D).
The used formula has the following logic:

Picture 3: transfering 2D-coordinates to 1D-coordinates

We just want to map a 2D coordinate (outer x and y lines) to a 1D coordinate (index inside the grid).
So if we want to know the index of y = 3 and x = 2 we know that for every step downward we have to add 5 (that’s the width of the Grid). So its y * width for the movement down on the y-axis. For every step to the left we just have to add the numbers we want to go to the left. That’s the movement on the x-axis. We end up with


index = y * GridWidth + x;”


In our example: 3 * 5 + 2 = 17. Starting at (0,0) == the upper left corner, means: If we go 3 down and 2 left we end up at the 17th Field.
This is how the convert2D_To_1D(…) - method converts every 2D coordinate to the propper 1D Coordinate inside the Grid. This allows us to store 2D-Coordinates as unsigned int Keys to a std::map.
The convert2D_To_1D(…) - method can be used both ways: You can use it as a Key to store a Value in a std::map and you can also use it to obtain the Value by the Key.
This is why the Grid is actually no 2D-Array. Its contents, the Fields, are stored continuously by the 1D-Coordinates in the mFields-Container. But the method convert2D_To_1D(…) let us use the Grid like it is really a 2D Entity.

void Grid::markLinesForRemoval() {
    if(mToRemoveBlocks) return;
    int countClearedLines{0};
    for (int y = mSize.y - 1; y > 0; --y) {
        int counter = 0;
        for (int x = 0; x < mSize.x; ++x) {
            auto field = getField(x, y);
            if (field->mOccupied) {
                counter++;
            }
            if (counter == 10) { // Line full
                mYCleaned.push_back(y);
                mToRemoveBlocks = true;
                countClearedLines++;
            }
        }
        counter = 0;
    }
    mGame.mHighScore.addClearedLines(countClearedLines);
    std::sort(mYCleaned.begin(), mYCleaned.end(), [](int left, int right) { return left < right; });
}

markLinesForRemoval() searches the Grid for all full lines on the y-axis and stores the y-coordinate of the full line in a std::vector (declared as std::vector<int> mYCleaned) so each full line can be cleared by cleanLines().

The line

if(mToRemoveBlocks) return; 

checks if there are still lines in the process of being deleted. If so, you don’t need to mark them again with markLinesForRemoval(), so just exit the method.
Next we create an int countClearedLines to count how many lines have been cleared. This value is needed by the Highscore-Class to calculate the appropriate score which depends on how many lines have been cleared.
We have to check which lines have all of their Fields occupied.
For this we have to determine if all x-positions on a specific y-position are occupied.
To do this, it does matter in which order we traverse through the x- and y-axis of the Grid. We want to traverse column for column and check for each column if all of its Fields on the x-axis are already occupied. For this check we start with

    for (int y = mSize.y - 1; y > 0; --y) {
        int counter = 0;
        for (int x = 0; x < mSize.x; ++x) {

The Y-based outerloop goes from bottom to top, so it takes care of the columns. For each column we need to check the whole line (so every Field on the X-axis). This is what the inner X-loop is for, which checks all rows from left to right for each column.

First we acquire a pointer called field to the actual Field-Object we are going to check:

auto field = getField(x, y);

then we check if the Field is occupied. If so, we raise the counter.
If the counter equals 10, every Field on the X-lane of the current column is occupied, so the line is “full”. We store the current column in our mYCleaned-Container because we need to know which lines have to be “cleared” in cleanLines() - method. Now we also know that at least one line (maybe even more) has to be cleared later on, so we set the mToRemoveBlocks - flag to true. And last but not least we have to increase the countClearedLines - variable.

After checking every Field and leaving both for-loops, we should finally know how many lines are full.

So the last step is:

mGame.mHighScore.addClearedLines(countClearedLines);
std::sort(mYCleaned.begin(), mYCleaned.end(), [](int left, int right) { return left < right; });

we pass the total number of cleared lines to the Highscore-Class for computing the score. Then we sort the mYCleaned-container in descending order, using a Lambda. This is how we ensure that we process the columns in a strict top to bottom order in the cleanLines() - method.

void Grid::cleanLines() {
    if (mYCleaned.empty()) return;

    for (auto i : mYCleaned) {
        for (auto y = i; y >= 0; --y) {
            for(auto x = 0; x < mSize.x; ++x){
                int up = y - 1;
                if(up < 0) continue;
                *getField(x,y) = *getField(x,up);
            }
        }
    }
    mYCleaned.clear();
    mToRemoveBlocks = false;
}

cleanLines() takes care of the re-arranging all of the other lines that are still in the Grid when clearing full lines. For this it needs the data from the mYCleaned-vector to know which lines are full and should be cleared.
If the mYCleaned-vector is empty, nothing has to be done and we can exit the method. Actually this line is redundant because it has been already checked by the update() - method if something has to be cleared, so it will never trigger. However I like having it in there in case the update()-method gets changed for whatever reason and the mentioned check in update() is not to be performed anymore.
Next we want to hop into each column that has to be “cleared” and descend every Field that resides above it, so all Fields “fall down”.


Because we did sort mYCleaned in markLinesForRemoval() before, we compute all "full-line columns" strictly from top to bottom inside the ranged based for-loop. In Picture 4 the only full line appears on the bottom of the Grid. We have to pull every Field that is above the full line down one step.

for (auto y = i; y >= 0; --y) {

makes sure that we cover every column above the full line and traverse “up” in columns.

for(auto x = 0; x < mSize.x; ++x){

takes care of all Fields on the x-axis (rows). 
This way we cover all Fields above the current “i”-column stored in mYCleaned. We just need to know the Field directly above the current Field so we can pull it down. We obtain it by the variable “up” which is always one above the current Field (up = y – 1).

*getField(x,y) = *getField(x,up);

ensures, that every value of the Field above (getField(x,up)) is assigned to the actual Field (getField(x,y)), because the upper Field will take the position of the current Field and consequently the current Field has to obtain all of the upper-Fields values. 
For this task the overloaded assignment operator we discussed before, that is defined in the Field-struct, is very handy. This is how every Field on the Grid will descend a whole column when a line is cleared.
This step will be repeated for every cleared line in mYCleaned. So all blocks above the actual line will descent as many steps as lines have been cleared.

void Grid::blink() {
    int num = int(mElapsedTime * 5.f); // speeds up blinking
    for (auto y : mYCleaned) {
        for (int x = 0; x < mSize.x; ++x) {
            getField(x,y)->mVisible = (num % 2 != 0);
        }
    }
}

This is the last method of the Grid-Class that has not been covered yet.
It takes care of the blinking effect of full lines. int num receives the elapsed Time. Because it is an integer-type, the decimal places of mElapsedTime get cut off when storing its value in num.
Next we go through each line that has to be cleared

for (auto y : mYCleaned) {

and set each of its Fields to visible or non-visible depending how much time has passed.

for (int x = 0; x < mSize.x; ++x) {
    getField(x,y)->mVisible = (num % 2 != 0);
}

the visibility is dependent of the current time-frame as illustrated below:

Picture 5: setting mVisible

num % 2 != 0 checks if we have an even or uneven number stored in num. The current row will be displayed when the elapsed time-interval is uneven. And with that we have covered the Grid-Class.
In Part III of this Tutorial we are going to discuss the Tetromino-Class.

Keine Kommentare:

Kommentar veröffentlichen