Samstag, 21. Juli 2018

Tetris made with SFML: Part III

In Part I and Part II i described the Field- & FieldInfo structs and the Grid-Class. It's time to introduce the Tetromino-Class.

Overview of the Tetromino-Class


class Tetromino : public sf::Drawable{
public:
    Tetromino(sf::Texture& texture, int id);
    ~Tetromino() = default;
    void rotate();

    void move(Direction dir);
    void setPosition(const sf::Vector2i& position);
    void revertState();
    std::array<sf::Vector2i, 4> getBlockPositions() const;
    std::array<sf::Vector2i, 4> getFutureBlockPositions(Direction direction) const;
    int getID() const;
private:
    void draw(sf::RenderTarget &target, sf::RenderStates states) const override;
    sf::Vector2i                    mPosition;
    int                             mCurrentRotation;
    int                             mID;
    std::array<sf::Vector2i, 4>     mBlock;
    std::array<sf::Vector2i, 4>     mOldBlock;
    mutable sf::Sprite              mSprite;
};


Class members


Each Tetromino needs its position that can be changed by the player. Its position is stored by mPosition. The position of the Tetromino will be inside the boundaries of the Grid and thus use its 2D-coordinates ranging inside 10 x 18. mCurrentRotation tracks the current rotation.

We won't rotate the Square-Tetromino. 

Picture 6: Square Tetromino

It does not make sense to rotate a squared object, because it would not change its outline/shape. Rotating the square would also lead to problems regarding rotating around the pivot-point which would be set to the lower right block of the square. I'll illustrate the problem below which occures also using the I-Tetromino and partly with the Z-Tetromino. It will be discussed in the rotate()-method in detail. However, not having the need to rotate the square is convenient.

Picture 7: I-Tetromino

You could set I-Tetrominos pivot point for rotation to the upper-middle or lower-middle Block. Both would lead to problems when rotating the block 180 degrees and 270 degrees as illustrated below:

Picture 8: Rotation Problem


As you can see it is problematic that the pivot point cannot be set at the exact center of the Tetromino. Thus the rotation is "misalligned" (see how the Tetromino leaves the virtual 4x4 Square). We have a similar problem with the Z and Reverse-Z Tetromino:

Picture 9: Rotation Problem

The Tetromino stays in the 4x4 square, but it is misalligned every second rotation as shown below when comparing the 0 and 180 degree rotation. The same applies for the 90 and 270 degree rotation:

Picture 10: Rotation Problem

The solution for this is simple:

Picture 11: 2 rotations
Picture 12: 4 rotations

     


Picture 13: 2 rotations
Picture 14: 4 rotations

     




















switching between two states (0 Degrees and 90 Degrees) will literally cover all "4 rotation-states" for the I, Z and Reverse-Z Tetrominos without having a misalligned rotation. 
The mCurrentRotation variable keeps track of which rotation is currently active for exact this purpose: If we are at 0 Degrees (mCurrentRotation == 0) and need to rotate the I-Tetromino by 90 Degrees, the next mCurrentRotation would be 1; If we want to rotate further by 90 Degrees, we set mCurrentRotation for each further Rotation back to 0, to 1, to 0, to 1 and so forth. 
mID stores the ID of the Tetromino which is needed by the already discussed Grid::addBlock(...) - method to point the stored blocks of the Tetromino on the Grid to its corresponding FieldInfo-object. 
mBlock and mOldBlock both store the positions of the 4 Blocks of each Tetromino. So it stores basically the shape of each Tetromino, represented by the positions of its 4 blocks. 
The positions of each block will naturally change during rotation. Because of that we need to store the old Position in mOldBlock in case the rotation fails when a Field on the Grid is already occupied. mOldBlock allows us to restore the last valid state when a rotation fails. 
The last member is mSprite, which will be used to draw the blocks of the Tetromino, while the Tetromino is not added to the Grid (thus still "exists"). You may have noticed that the Tetromino-class inherits from sf::Drawable. This allows us to draw the Tetromino-Class like a normal sf::Drawable-object with sf::RenderWindow
However we have to overload the

void sf::Drawable::draw(sf::RenderTarget& target, sf::RenderStates states) const

- method of the abstract sf::Drawable - class. This is done by using our

void Tetromino::draw(sf::RenderTarget& target, sf::RenderStates states) const override

- method. Because the draw() - method must be declared const, we are not allowed to alter the Tetromino-class within the draw(...) - method. For that reason mSprite is declared mutable because we need to alter it in the draw(...) - method.

Tetromino::Tetromino(sf::Texture &texture, int id)
: mPosition(sf::Vector2i{3,0}), mCurrentRotation(0), mID(id), mBlock(), 
  mSprite(texture, sf::IntRect{(id % 7 )* 18, 0, 18, 18}){
    mID = mID % 7; //restrict so it does not get out of bound
    for(auto i = 0; i < 4; ++i){
        mBlock[i].x = BlockInfo4x4[mID][i] % 4;
        mBlock[i].y = BlockInfo4x4[mID][i] / 4;
    }
}

In the initialization-list we initialize all members to default values. The default starting Position assigned to mPosition is at 2D-Grid-Coordinate x = 3, y = 0. We use a constructor-overload of sf::Sprite which takes in an sf::Texture and a sf::IntRect to determine which part of the texture should be shown by the Sprite. The Texture is here the same as shown in Picture 2. We use the passed ID the same way we did in the FieldInfo-constructor to carve the appropriate block out of the Blocks.png using a sf::IntRect{...}. This way we obtain also a unique color for each unique Tetromino.
The next part is a slightly more tricky.

The Shape of the Tetromino

We basically want to store the positions of each block that belongs to a Tetromino as 2D-Coordinates so we can hold its "shape" in the mBlock-array.

Picture 15: BlockInfo4x4

The I-Tetromino would basically consist of 4 different sf::Vector2i-Positions: 
mBlock[0] = sf::Vector2i{1,0}; 
mBlock[1] = sf::Vector2i{1,1}; 
mBlock[2] = sf::Vector2i{1,2}; 
mBlock[3] = sf::Vector2i{1,3};

You could store 4 positions for every Tetromino manually like its shown above.

Another way would be to create a 2D-array and store the 4 block-positions of each Tetromino as a 1D-coordinate, basically as we did in the discussion of Grid::convert2D_to_1D(int x, int y)-method.
In Picture 15 the 1D-coordinates for the I-Tetromino would be: 1, 5, 9, 13.
We could store for each Tetromino its 4 positions as 1D coordinates and load the appropriate set depending on the current ID.

unsigned int BlockInfo4x4[7][4] = {
        {4, 5,  8,  9 },    // Square
        {5, 9, 13,  12},    // Reverse-L
        {5, 9, 10,  14},    // Z
        {5, 9,  8,  12},    // Reverse-Z
        {5, 8,  9,  10},    // T
        {5, 9,  13, 14},    // L
        {1, 5,  9,  13},    // I
};

In this 2D-matrix every block-position of every Tetromino is stored. Every column (there are of course 7 of them) belongs to one Tetromino and has 4 entries (rows), each storing the 1D-coordinate of a block.

for(auto i = 0; i < 4; ++i){
    mBlock[i].x = BlockInfo4x4[mID][i] % 4;
    mBlock[i].y = BlockInfo4x4[mID][i] / 4;
}

Because there are only 4 block-positions to load, the for-loop has 4 iterations to load every block in the column.

We need transfering the 1D-coordinate for each entry back to a 2D-coordinate.
mID controls which column we are going to load. The i-index goes the row from left to right.
To transfer the stored 1D-coordinate back to 2D we use the following formulas:
X-coordinate = 1D-coordinate % BlockWidth.
Y-coordinate = 1D-coordinate / BlockWidth.
For example if we pick the index 13 in Picture 15 we should end up with x = 1 and y = 3;
1 = 13 % 4 ;
3 = 13 / 4;
When a Tetromino is created, it gets a unique ID passed which will determine it's shape, because the ID is used to locate the column of the 2D-array of BlockInfo4x4. The i-index of the for-loop will be used to load the corresponding Row. That's all there is to load the Tetromino-shape into mBlock.
Now we should already be able to draw the Shape of the Tetromino, which is done by the draw(...) - method:

void Tetromino::draw(sf::RenderTarget &target, sf::RenderStates states) const {
    for(int i = 0; i < 4; ++i){
        mSprite.setPosition((mBlock[i].x * 18) + (mPosition.x * 18), (mBlock[i].y * 18) + (mPosition.y * 18));
        target.draw(mSprite);
    }
}

Because there are 4 blocks to draw, our for-loop has 4 iterations.
For positioning-calculation we use the coordinates of the 10 x 18 Grid (x goes from 0 to 10 and y from 0 to 18). However when drawing to the actual playing - field on the monitor, we have to take the width and height of each block into account that is 18 px.
Also we have to take our current "Tetromino-position" (stored in mPosition) into account to draw the block at the correct position. The position being stored in 10x18 Grid-dimensions finally also has to be "scaled" by 18 px for the monitor output.
And thats basically it: The Tetromino should be drawn with correct shape and color.

Rotation


void Tetromino::rotate() {    
    //store state of Block in case rotation turns out to be invalid
    mOldBlock = mBlock;
    mCurrentRotation++;

    if(mID == 0){ //square: no need for rotation
        return;
    }
    if(mID == 6 || mID == 2 || mID == 3){ // rotation of I, Z and Reverse-Z restricted to two states(horizontal/vertical)
        for(auto i = 0; i < 4; ++i) {
            sf::Vector2i oldPoint = mBlock[i];    //pivot
            sf::Vector2i localVector = oldPoint - sf::Vector2i{1, 2};
            sf::Vector2i nextPoint{};
            if(mCurrentRotation % 2 == 1){
                /* counter-clockwise
                 * [0  -1]
                 * [-1  0]*/
                nextPoint = sf::Vector2i{(0 * localVector.x) + (-1 * localVector.y),
                                         (1 * localVector.x) + (0 * localVector.y)};

            }
            else{

                nextPoint = sf::Vector2i{(0 * localVector.x) + (1 * localVector.y),
                                         (-1 * localVector.x) + (0 * localVector.y)};

            }
            mBlock[i] = sf::Vector2i{1,2} + nextPoint;
        }
        return;
    }
    for(auto i = 0; i < 4; ++i){
        sf::Vector2i oldPoint = mBlock[i];    //pivot
        sf::Vector2i localVector = oldPoint - sf::Vector2i{1,2};   // 1, 1

        /*//Rotation Matrix
         * [cos Degree    -sin Degree]
         * [sin Degree     cos Degree]
         * translates to
         * clockwise
         * [0   -1]
         * [1    0]
         * */

        sf::Vector2i nextPoint {(0 * localVector.x) + (-1 * localVector.y),
                                (1 * localVector.x) + (0 * localVector.y)};
        mBlock[i] = sf::Vector2i{1,2} + nextPoint;
    }

We could have stored each "shape" of each rotation in a vector and load the propper "next" shape each time the Player rotates the Tetromino. However this would be tedious to do, so i decided to make the rotation happen using some math. This was the hardest part for me to solve, but as always dealing with math, it turned out to make somehow sense.
This tutorial helped me a lot getting the rotation to work:





mOldBlock = mBlock;

First we store our current mBlock-state in case the Game-class decides, that the rotation has to be undone because it collides with a wall or block on the Grid.
Because a rotation is taking place, we have to raise the mCurrentRotation - counter to keep track of the current number of rotations. This is needed for the back-and forth-rotation of the I-, Z- and Reverse-Z-Tetrominos.
Next we check which Tetromino we are dealing with. The Shape depends of the mID because we used the same mID to load the shape from the BlockInfo4x4-2D-array.

if(mID == 0){ return;} //square: no need for rotation

We don't want to rotate the Square-Tetromino, so we return here. 
The I-, Z- and Reverse-Z-Tetromino needs special treatment, because of the rotation-problem illustrated in Picture 8 and 9. 
We want it to rotate clockwise or counter-clockwise dependend of mCurrentRotation which keeps track how many rotations have been done by the Tetromino.

if(mID == 6 || mID == 2 || mID == 3){ // rotation of I, Z and Reverse-Z restricted to two states(horizontal/vertical)
    for(auto i = 0; i < 4; ++i) {
        sf::Vector2i oldPoint = mBlock[i];    //pivot
        sf::Vector2i localVector = oldPoint - sf::Vector2i{1, 2};
        sf::Vector2i nextPoint{};

So first we make sure we are dealing with a I-, Z- or Reverse-Z-Tetromino.
Because the rotation has to affect all 4 blockpositions stored in mBlock, we have once again to iterate 4 times a for-loop to adress every block.
For the rotation we first have to obtain the current position of the block we want to rotate.
Than we have to calculate the vector between that block-position and the pivot/center we want to rotate around. To rotate the new obtained vector localvector we have to apply the rotation-matrix on it. The result of that "rotation-matrix-multiplication" will be stored in the newly created variable nextPoint of type sf::Vector2i.

mBlock[i] = sf::Vector2i{1,2} + nextPoint;

To get the final position of the rotated block, we have to add the rotated vector nextPoint to the pivot. Thats it.

if(mCurrentRotation % 2 == 1){ ... } else{...}

ensures that we have the correct direction of the rotation when rotating the I-, Z- or Reverse-Z-
Tetromino. We change the rotation-direction by slightly changing the rotation-matrix as shown in the code-comments.
The last for-loop takes care of the rotation of every other Tetromino not covered yet. They behave all the same, so we're good here.

Movement

Before discussing the Tetromino::move(Direction dir) - method the enum class Direction should get introduced first. Its stored in its own Direction.hpp - header, because the Game- and Tetromino-class will use Directions.

#ifndef DIRECTION_HPP
#define DIRECTION_HPP
enum class Direction{
    Left = 0, Right = 1, Down = 2, SoftDown = 3
};
#endif //DIRECTION_HPP

A Tetromino can only move left, right or down. The only notable thing about the Direction-enum is the distinction between "Down" and "SoftDown". Both enums will cause the Tetromino to descend the same way as illustrated in the move(...) - method below. However for the Highscore-System it is important to distinct between a normal step down caused by "gravity" (Direction::Down) and a player-induced step down (Direction::SoftDown) because the latter will be rewarded with a small bonus when the Tetromino finally collides with the bottom or a block in the Grid.

void Tetromino::move(Direction dir) {
    if(dir == Direction::Left){
        mPosition.x--;
    } 
    else if(dir == Direction::Right){
        mPosition.x++;
    }
    else{
        mPosition.y++;
    }
}

this method is self-explaining, soo there is not much to say about it. As you can see Direction::Down and Direction::SoftDown are treated the same in terms of movement.

std::array<sf::Vector2i, 4> Tetromino::getBlockPositions() const {
    std::array<sf::Vector2i, 4> blockPositions;
    for(auto i = 0; i < 4; ++i){
        blockPositions[i] = sf::Vector2i{mBlock[i].x + mPosition.x, mBlock[i].y + mPosition.y };
    }
    return blockPositions;
}

getBlockPositions() returns just the current 4 blocks (respectively their positions) and takes the current Position of the Tetromino (stored in mPosition) into account by adding it to each block-position.

std::array<sf::Vector2i, 4> Tetromino::getFutureBlockPositions(Direction direction) const {
    std::array<sf::Vector2i, 4> blockPositions;
    sf::Vector2i tempPosition{mPosition};
    if(direction == Direction::Left){
        tempPosition.x--;
    }
    else if(direction == Direction::Right){
        tempPosition.x++;
    }
    else {
        tempPosition.y++;
    }
    for(auto i = 0; i < 4; ++i){
        blockPositions[i] = sf::Vector2i{mBlock[i].x + tempPosition.x, mBlock[i].y + tempPosition.y};
    }
    return blockPositions;
}

getFutureBlockPositions(Direction direction) does basically the same as the getBlockPositions(...), with some differences.
The Game-class has to know if an intended movement would be valid on the Grid. So it need the "future position" of the Tetromino to determine if this future position is valid in regard to the Grid.
So we return a block-array that represent the block-state, when the passed Direction would have actually been applied to the Tetromino-class. As you can see we do not alter the actual mBlock- and mPosition - variable that stores the current state of the Tetromino.

void Tetromino::revertState() {
    mBlock = mOldBlock;
}

int Tetromino::getID() const {
    return mID;
}

void Tetromino::setPosition(const sf::Vector2i& position) {
    mPosition = position;
}

Last but not least the last methods to be discussed: The revertState() - method is needed when the Game-Cass determines that a rotation was actually invalid. This is not handled the same way as movement (see getFutureBlockPositions(...)).
We acturally make the rotation happen and if it turns out to be invalid, we just go back to the last valid state before rotation. revertState() takes care of that. This will be discussed further in part IV of the tutorial were the Game-class will be introduced.

Keine Kommentare:

Kommentar veröffentlichen