Donnerstag, 19. Juli 2018

Tetris made with SFML: Part I

I always was curious about how Tetris really works “under the hood”. So I looked it up at github. There are several interesting Tetris Implementations. They all had in common that it was hard to follow along.  I decided to code my own implementation and write about it so i have some form of documentation if i should look at the code and wonder what i was actually doing. However writing everything down i ended up with 22 pages not even covering the Game-Class yet. So i have decided to split this article into four parts.
If you just want to grab the code, here it is: https://gitlab.com/Zaunfish/TetrisClone
Picture 1: The 7 different Tetrominos

The overall Idea:
Each Tetromino will consist of 4 Blocks. All 4 Blocks positions will be stored in an sf::Vector2i-array belonging to the Tetromino-Class. There will always be one free descending Tetromino which can be controlled by the player. When this Tetromino finally hits the Bottom or another Tetromino stored in the grid, it will get passed to the Grid and become a part of it. The 10 x 18 sized Grid will take care of the “clearing-the-lines”-logic. The Game-Class will manage everything else (Controls, Gravity, Collision etc.) There also should be a Highscore-Class which takes care of calculating the score.

Basically 4 different classes should do the job:
  1. The Game-Class, which manages everything.
  2. The Tetromino-Class: There are 7 different Shapes which has to be represented in code. This is what the Tetromino class is for. The Tetromino class keeps track of the Shape, Positioning, Rotation etc. of the Block.
  3. The Grid-Class. It keeps track of which Fields are occupied and clean full lines.
  4. The Highscore-Class: Keeps Track of the Highscore and displays it.
I also created 2 structs that are only used in the Grid-Class, thus are declared in the Grid.hpp Header. 
  1. Struct FieldInfo: just stores the sf::Sprite which will display the Blocks in the Grid. Because there are only 7 different Tetrominos/Blocks with each having a different color, we don’t need every Field to have its own Sprite -  this would be a waste of recources. The Grid being 10 x 18 fields big, this way we end up just using 7 Sprites (each stored in separate FieldInfo-Objects) instead of 180 Sprites that would reside in all the Fields. I’ll come back to this later. A single sf::Texture will load a Picture containing all the different colored blocks (see Picture 2).
  2. Struct Field: It represents the actual Field in the Grid that stores some Field-related values, for example, if it is occupied or visible.

The Field and FieldInfo-Structs
Here is the declaration of the two structs that helps keeping track of the State of the Grid.

struct FieldInfo{
    FieldInfo(sf::Texture& texture, int id);
    sf::Sprite          mSprite;
};

struct Field{
    Field& operator=(const Field& field);
    bool               mOccupied = false;
    bool               mVisible = true;
    FieldInfo*         mInfo = nullptr;
};

It’s basically a flyweight-pattern. Because there will be 180 Fields (10 x 18) but a Field only can hold 1 of 7 different Blocks, it seems a bit of an overkill to have for each Field-Object carrying its own sf::Sprite. After all we just need 7 different sprites each displaying a different colored block.  

FieldInfo-Struct
For this reason 7 different FieldInfos will be stored in a std::map and each Field on the Grid will point to its appropriate FieldInfo via its mInfo-Pointer when it gets occupied by a Tetromino. With that we don’t need each Field on the Grid having its own sf::Sprite. sf::Sprite is a pretty lightweight object, so it actually is a bit of an overkill to use the Flyweight-Pattern for just 180 Field-Objects, but on the other side it is good practice to keep Objects slim when you can do it without too much hassle. With this approach the Field-Objects basically only consists of two Booleans and a Pointer.
The FieldInfo-Constructor takes in an sf::Texture which is needed for the Sprite to display the loaded image. Second it takes in an ID which represents one of the 7 possible Tetrominos and which is needed to determine the appropriate Block from the Blocks.png. As seen in Picture 1, each Tetromino has its own color. 


Picture 2: Blocks.png

FieldInfo::FieldInfo(sf::Texture& texture, int id) {
    sf::IntRect rect{(id % 7) * 18, 0, 18,18};
    mSprite.setTexture(texture);
    mSprite.setTextureRect(rect);
}

As you can see in Picture 2 the Blocks.png has 7 different colored Blocks. Each Block can be used for a different Tetromino. The Image being 126 x 18 px and having 7 Blocks on the x-axis makes a square block-size of 18 px.
So first we create an sf::IntRect that has to wrap the correct Block out of the Image. Next we assign the texture to the sf::Sprite and set the sf::TextureRect. That’s it. Now we control which block will be displayed by the ID that’s passed to the FieldInfo-Object when its being created.
When creating a FieldInfo and passing it the ID “3”, the Sprite should display only the green block.

Field-Struct
Remember the Field-Struct? (I hope so, because it has been introduced just a few sentences ago)
It has a FieldInfo-Pointer mInfo which we now can point to the corresponding FieldInfo-Sprite. If a T-shaped Tetromino (which for example has the ID “4”) hits the Grid, its 4 Blocks will be laid out on the Grid. Each occupied Field on the Grid will have to point to the FieldInfo with the ID “4”. That’s how we get a yellow T-shaped Tetromino displayed on the Grid. Its Block-Positions will “paint” its Shape and its ID will determine its color. How this exactly works is discussed in the Grid::addBlock()-method.

Then we’re going to overload the Assignment-Operator. 

Field& Field::operator=(const Field& field) {
    this->mOccupied = field.mOccupied;
    this->mVisible = field.mVisible;
    this->mInfo = field.mInfo;
    return *this;
}

This is used for convenience-reasons only: When the full lines get cleared, each Field above the line has to descent. For this we need to assign the status of every Field to the Field below it. This will be described  in detail when discussing the cleanLines() – Method.
There is not much more to say about the two structs. Now that we know how the Field- and FieldInfo-structs will be used, let’s move on to the Grid-Class.

Overview of the Grid-Class

class Grid {
public:
    Grid(sf::Vector2i size, Game& game);
    Grid(const Grid& other) = delete;
    Grid& operator= (const Grid& other) = delete;

    void update(const sf::Time& dt);
    void clean();
    void addBlock(int id, std::array<sf::Vector2i, 4> block);
    bool isOccupied(std::array<sf::Vector2i, 4> block);
    void draw(sf::RenderWindow& window);
    void printGrid();
    inline bool isToRemoveBlocks() const { return mToRemoveBlocks;}
    Field* getField(int x, int y);
private:
    int convert2D_to_1D(int x, int y);
    void cleanLines();
    void markLinesForRemoval();
    void blink();

    Game&                                                        mGame;
    std::unordered_map<unsigned int, std::unique_ptr<Field>>     mFields;
    std::unordered_map<unsigned int, std::unique_ptr<FieldInfo>> mFieldInfo;
    sf::Vector2i                                                 mSize;
    std::vector<int>                                             mYCleaned;
    float                                                        mElapsedTime;
    bool                                                         mToRemoveBlocks;
};

Lets start first with the members:
mGame is our reference to the Game-Class so the Grid can use its methods and members.
The mentioned Fields and FieldInfos are stored in std::map and can be accessed by an unsigned int as their Key-Value.

There will be 7 FieldInfo-Objects stored in the container mFieldInfo. Each FieldInfo-Object will be stored by an unique ID from 0 to 6 as it's Key-Value, which corresponds to the same unique ID of each Tetromino. The addBlock(…) – method takes care of putting the Shape of the Tetromino into the Grid and connect each Field on the Grid with the corresponding FieldInfo, that holds the right sf::Sprite thus the corresponding Part of the Blocks.png-Image for the Field.

mFields stores all Fields by their “1D Coordinate” (unsigned int) as Key-Value created from 2D coordinates. “The Bridge” between those is the helper-method int convert2D_to_1D(int x, int y).
mSize stores the Dimensions of the Grid (which will be sf::Vector2i{10, 18}).
mYCleaned stores just the y-positions of all full lines collected by the void markLinesForRemoval()-method. It is needed by the clearLines()-method that needs to know which lines have to be removed based on the "cleared" y-positions stored in mYCleaned.
mElapsedTime keeps track of the elapsed Time and last but not least:
mToRemoveBlocks keeps track if the process of removing Blocks is “ongoing”. Because full lines are not supposed to vanish instantly but to blink for a small amount of time, mToRemoveBlocks has to keep track if this is the case.
That were all members of the Grid-Class. 


In Part II i will describe the implementation of the Grid-methods.


Keine Kommentare:

Kommentar veröffentlichen