Sonntag, 17. Juli 2022

Setting up Rust SFML with Mingw-w64 on Windows


 

Out of curiosity i wanted to take a look at Rust and to see how the "Hello World" with the SFML Library would look like, which would basically open a graphics window and render something onto the screen. Following the available documentation on several online-sources turned out to not work out for me and getting Rust-SFML to compile with Mingw-w64 on Windows turned out to be tricky. Here are the steps how i have done it: 

Prerequisites: 

1. Mingw-w64 Compiler (get it from here: https://winlibs.com/)

2. CSFML 2.5.1 (get source from here: https://www.sfml-dev.org/download/csfml/)

3. SFML 2.5.1 (get source from here: https://www.sfml-dev.org/download/sfml/2.5.1/)

4. Rust Toolchain (download rustup-init.exe from https://rustup.rs/)

5. CMake (get from https://cmake.org/download/)

I. Installing Rust Toolchain

When running rustup-init.exe we need to tell rustup that we do not want to use the default MSVC ABI but Mingw-w64.

When starting up rustup-init.exe you will see that the toolchain defaults to "x86_64-pc-windows-msvc". Here we need to change that to mingw. For this we choose the 2) option ("Customize installation") and provide the value "x86_64-pc-windows-gnu":

Leave the rest at default.

We end up with something like this 

 

 and can now proceed with option 1) ("Proceed with installation (default")

 II. Install SFML and CSFML

SFML must be installed before CSFML, since CSFML depends on SFML (at least thats what CMake tells us if we try to install CSFML first). 

When CMake asks to specify the generator for this project select "Mingw Makefiles" and generate the CMake-Files in the previously specified build-folder (here i selected C:/SFML as my build-folder for SFML). Use CMD with admin-privileges, enter into the build-folder and install SFML with the command "mingw32-make install".

The compiled lib- and dll-files will reside in C:\Program Files (x86)\SFML.

Repeat the process now with CSFML, just remember to use a different build-folder (like C:/CSFML) for the CMake-Files. We should end up with lib-and dll-files for CSFML in the appropriate subfolders in C:\Program Files (x86)\CSFML.

We are already halfway there.

III. Setting up IDE and Libraries

add sfml to Cargo.toml:

[package]
name = "your_project_name"
version = "0.1.0"
edition = "2021"

[dependencies]
sfml = "0.16.0"

The rust-sfml example code that is being found on the official docs (https://docs.rs/sfml/0.11.2/sfml/) does unfortunately not compile because it is outdated and relies on old versions of SFML and CSFML.

However, the following "Hello World" should do:

extern crate sfml;

use sfml::system::Vector2f;
use sfml::window::{ContextSettings, VideoMode, Event, Style};
use sfml::graphics::{RenderWindow, RenderTarget, CircleShape, Color, Transformable, Shape};

fn main() {
    // Create the window of the application
    let mut window : RenderWindow = RenderWindow::new(VideoMode::new(800, 600, 32),
                                                      "SFML Example",
                                                      Style::CLOSE,
                                                      &ContextSettings::default());

    // Create a CircleShape
    let mut circle = CircleShape::new(1.0, 12);
    circle.set_radius(30.);
    circle.set_fill_color(Color::BLUE);
    circle.set_position(Vector2f::new(100., 100.));

    while window.is_open() {
        // Handle events
        for event in window.poll_event() {
            match event {
                Event::Closed => window.close(),
                _             => {/* do nothing */}
            }
        }
        // Clear the window
        window.clear(Color::RED);
        // Draw the shape
        window.draw(&circle);
        // Display things on screen
        window.display()
    }
}

Unfortunately, this will still not compile, because Rust will not find the needed libs. Here the previously compiled SFML and CSFML libraries come into place: 

Grab all *.a files from C:\Program Files (x86)\CSFML\lib

and copy them to

C:\Users\USER\.rustup\toolchains\stable-x86_64-pc-windows-gnu\lib\rustlib\x86_64-pc-windows-gnu\lib

Now the code example should compile. If you try to actually run it, it will complain that dll-files are missing. These can be found in C:\Program Files (x86)\CSFML\bin and must be copied to the target-folder of the project, where the compiled *.exe file of the program is located. 

Now the rust-sfml project should compile and run.

Freitag, 26. Juni 2020

Setting up SDL2 on Windows with CMake and MinGW-W64 (Minimal Example)




Coming from SFML i noticed that setting up SDL2 on Windows using CMake can be...quite tricky. The official Wiki at https://wiki.libsdl.org/Installation gives only little information on how to build and install SDL2 on Windows to utilize the library for your projects.

There is an older article describing how to setup SDL2 with CMake and some StackOverflow discussions regarding this topic. They all had in common that they did not work for me - i could not get my project using SDL2-facilities to run on my Windows machine, no matter what.

While SFML 2.5 Source Code comes with an own SFMLConfig.cmake file that takes care of finding the corresponding libraries when invoking find_package(...) in CMake, this seems to be broken for the SDL2 Source Code that can be downloaded from the official SDL2-Website - at least for the Windows Plattform.
So there is no working  find_package(SDL2 REQUIRED) out of the box.


The "Using FindSDL2.cmake" Route

Another approach is using some FindSDL2.cmake file that is floating around in the internet. You could create a modules-directory in the source directory of your project (where the CMakeLists.txt file resides). CMake should be informed about the modules folder where it will look for a FindXXX.cmake file:

set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/modules)

In theory find_package(SDL2 REQUIRED) should now find the SDL2 files. However this did not work for me either, CMake was not able to find SDL2 on Windows despite using a FindSDL2.cmake-file. Grrr.
The available FindSDL2.cmake files you can find online are tailored for UNIX-derived operating systems (such as Linux or OSX), and do not consider Windows as a Target Plattform. In short: The usual FindSDL2.cmake files you can find online do not search for Windows folders. So you would basically need to adjust the FindSDL2.cmake file to get it to work in Windows.
Such FindSDL2.cmake file for example can be found here or here.
If you installed SDL2 in "C:/Program files (x86)/SDL2" you would have to adjust the FindSDL2.cmake-file as follow:

before:
SET(SDL2_SEARCH_PATHS
 ~/Library/Frameworks
 /Library/Frameworks
 /usr/local
 /usr
 /sw # Fink
 /opt/local # DarwinPorts
 /opt/csw # Blastwave
 /opt
)

after:
SET(SDL2_SEARCH_PATHS
 ~/Library/Frameworks
 /Library/Frameworks
 /usr/local
 /usr
 /sw # Fink
 /opt/local # DarwinPorts
 /opt/csw # Blastwave
 /opt
 "C:/Program Files (x86)/SDL2"
)

Now CMake should find the SDL2-files - given you installed them in this location. The CMakeLists.txt file could now be written as follow:

cmake_minimum_required(VERSION 3.0)
project(game)
set(SOURCES main.cpp)
set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/modules)
find_package(SDL2 REQUIRED)
include_directories(${SDL2_INCLUDE_DIR})
add_executable(game ${SOURCES})
target_link_libraries(game ${SDL2_LIBRARY})

Keep in mind that due to how the used FindSDL2.cmake file is written you don't write
#include <SDL2/SDL.h> 
in your *.hpp/cpp Project-files but simply
#include <SDL.h>
given that your SDL2-include-files are stored in C:/Program files (x86)/SDL2/include/.

 

The "Straight-Forward" Route

If you don't like fiddling with the FindSDL2.cmake-File you can just directly tell CMake about SDL2 as straight forward as possible. This turned out to be bumpy, but it works too.

cmake_minimum_required(VERSION 3.0)
project(game)
set(SOURCES main.cpp)
set(SDL2_INCLUDE_DIR "C:/Program Files (x86)/SDL2/include")
set(SDL2_LIBRARY_DIR "C:/Program Files (x86)/SDL2/lib")
include_directories(${SDL2_INCLUDE_DIR})
add_executable(game ${SOURCES})
target_link_libraries(game -lmingw32 ${SDL2_LIBRARY_DIR}/libSDL2main.a ${SDL2_LIBRARY_DIR}/libSDL2.dll.a -mwindows)
The important bits here are the following:
The FindSDL2.cmake file did hide some specifics from us that we now need to take care of:
The FindSDL2.cmake file suggests that you need to link against following files: -lmingw32 -lSDL2main -lSDL2 -mwindows
Since we don't use a FindSDL2.cmake-file, we do have to do this manually.
  • libSDL2main.a must be linked before libSDL2.dll.a, otherwise you will get a plethora of linking-errors when you try to compile your project.
  • you must link against -lmingw32 when using mingw-w64 compiler, otherwise during compilation the compiler will complain about some "undefined reference to `WinMain'". The position matters, -lmingw32 has to be the first entry that is linked against in target_link_libraries.
I noticed that omitting -mwindows does not have a negative effect, but who knows - so keep it in there.

Compile and run

Having everything in place as described you should be now good to go when using a mingw-w64 compiler:
Given that the path to CMake and your mingw-w64-compiler (where g++.exe resides) is added to the environment variables PATH and your Project-Folder contains the described CMakeLists-file, open the command line tool of windows ("CMD"), go to your /ProjectFolder/build directory and create the Makefile with

cmake .. -G "MinGW Makefiles"

The created Makefile can be compiled with

mingw32-make

Now your project should compile with SDL2 included. Don't forget to add SDL2.dll to your build-folder where the *.exe of your application is created.

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.

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.