Waterdrop game, the first version

Let’s study a bit larger program comprising both objects and use of vectors. We will implement a game working like Splash back.

The game has a game board with squares, some of which are empty and the other ones have waterdrops of different sizes. The goal is to clean the board out of water. The player has a water tank, and they can drip water to some of the squares in order to increase the size of the waterdrop. The maximum size of a waterdrop in a square is 4 droplets. If you drip water more than that to a square, the drop will pop into four parts and splashes go into four directions. The splashes proceed until they reach the border of the game board and fall away or hit another drop and increase the amount of water of that drop with one droplet. The goal, i.e. an empty game board, can be reached by adding water to suitable squares to make drops to splash out of the game board.

Pull program code from your Git central repository. You can find code of the game implemented with simple ASCII graphics from the directory examples/04/waterdrop_game_v1. Let’s study the implementation.

Caution

During exploring the code you may want to edit it. Experimenting is usually a more efficient way to learn than just reading. Recall that you must not change the code in the examples directory. Otherwise you cannot pull other examples from the repository in sequel.

Instead, for editing, copy the directory examples/04/waterdrop_game_v1 to the directory called student/04/waterdrop_game_v1, and open the latter one in Qt Creator.

It is perhaps a good idea to run the program once before reading and exploring the code more precisely. When the program prints the prompt x, y>, the user is expected to give the coordinates of the square, in which they want to drip water. This is not so fluent as with a graphical interface, but we start in this way. The origin of the game board is on the left top corner, and you can see X coordinates on the top and Y coordinates on the left.

Data structures: game board and water drops

The game board is a two-dimensional grid that can be implemented as a two-dimensional vector.

../../_images/pelilauta1_en.png

Recall from the previous programming course how to unify data structures and how to refer to the elements of such structures.

../../_images/pelilauta2.png

If desired the same figure can be drawn in another order that can be easier to understand (it prevents from mixing X and Y coordinates). Each time you feel it difficult to understand a data structure, take a pen a draw a picture about it.

The squares of the game board contain waterdrops. A waterdrop includes data, e.g. how many droplets of water it has. A waterdrop has actions as well, you can, for example, add water to it when it grows or pops. Since a waterdop has both data and actions, we can implement it as an object.

Our game board is a two-dimensional vector, the elements of which are objects. However, the game board has squares that are empty of water. Is it possible to have a waterdrop object that has no water? For this reason, it is more natural to name this object as Square instead of Waterdrop. We have now a two-dimensional vector, the elements of which are squares that can be either empty or contain water:

class Square { ... };
std::vector< std::vector< Square > > board;

Since we need to refer to this data structure several times in the program, we define:

using Board = std::vector< std::vector< Square > >;

With this definition we can avoid repeating std::vector< std::vector< Square > >, which makes the program clearer and shorter. But where to write the above using clause? We will answer to this question by exploring the file division of the program.

Program files

The program is divided into three files as follows:

  • main.cpp comprises the main function and utility functions used by the main function.
  • square.hh comprises the interface of the class and program-wide definitions.
  • square.cpp comprises the implementations of the methods of the class.

At this point, let’s consider, if it were more clear to put the program-wide definitions to a .hh file of their own instead of square.hh. In a way, this would be a better solution, because the main task of square.hh is to give the definition of the class.

On the other hand, recall that a square is a part of the game board, and the game board consists of squares, and thus, these two concepts are very closely related to each other. Due to this, aforementioned using clause can be put in square.hh.

In addition, the program is rather small, and thus, no more files are needed. In larger programs with longer files, a separate file for the above purpose would be a good solution.

Explore also the #include directives of the files. You can notice the same practice, used also in previous examples, that square.hh has been included in both .cpp files, since its definitions are used these files.

Forward declation of a class

When reading the files, you can notice that before the using clause, square.hh has the line

class Square;

This is a forward declaration of class Square. It is needed, because the line

using Board = std::vector< std::vector< Square > >;

cannot be compiled, if the compiler does not know what Square is. On the other hand, the interface of Square class cannot be compiled, if the compiler does not know what Board is. In other words, Square should have been defined before Board, and also vice versa.

This problem can be solved by using forward declarations. Forward declaration of Square tells to the compiler that Square is a class. This is enough information for the compiler to enable it to compile the using clause.

Class Square

Attributes

Clearly, a square object must know, how many droplets of water it contains.

In addition, each square object should have access to other square objects, because it adds water to other squares when popping. For this reason, each square object has a pointer to the game board, in which it lies. Through the pointer, the square object has access to anything lying on the board.

Square object needs to deal with those squares that are either vertically or horizontally adjacent to it. Therefore, the square object must know its own location. With its own indices, it can index the game board, to which it has access through the aforementioned pointer.

Search for the definitions of the attributes declared above from program code.

Methods

In addition to the constructor and destructor, the square object comprises the following actions:

  • A square must know how to print itself, when the whole game board will be printed.
  • It must be possible to add water to a square.
  • The waterdrop in a square pops, when the amount of water grows over four droplets.
  • It must be possible to ask from a square, if it contains water. This information is needed, if a waterdop pops and splashes its water to other squares.

Find out the definitions of these methods from the interface of Square.

Printing, adding water, and asking the amount of water are clearly such methods that process a square object from outside the class. Therefore, these methods can be found from the public interface. For example, when the main function drips water from the tank to the game board, it calls addWater method of the square object with certain coordinates. As an other example, when a square object about to pop looks for the nearest square having water, it uses hasWater method of other square objects.

However, pop is not a method that can be called by anyone. Popping happens only when the amount of water of a waterdrop exceeds four droplets. Its execution is based on the internal state of the square object. Therefore, pop method can be put in the private interface, and the object itself can call it at the right time from another method.

Referring to methods with operators . and ->

When exploring the program more precisely, you can notice that the methods of the game board vector is sometimes called in a good old way as:

object.method(parameters);

Such a call can be found e.g. in the loop structure in main function:

board.at(y-1).at(x-1).addWater();

But sometimes a different way of calling is used:

object->method(parameters);

Such calls can be found e.g. in pop method.

The reason for the differences is that in some parts of the program we handle an object directly, and in some other parts we handle an object through a pointer. The main function has a vector object, but square objects have pointer to a vector object.

../../_images/metodien_kayttaminen_olio_en.png

When we use a named object itself, we call methods with the notation object.method(parameters), e.g. vector_object.size().

../../_images/metodien_kayttaminen_osoitin_en.png

When we use an object through a pointer, we call methods with the notation object->method(parameters), e.g. vector_pointer->size().

Qt Creator guides you to use the right notation. Try this by moving the cursor to one of the method definitions of Square writing to a new line board_. and see what happens.

Main function and other functions

To avoid the main function to grow too long, file main.cpp has utility functions

  • to initialize the game board with waterdrops of random sizes
  • to print the game board
  • to check, if the game board is empty of water, i.e. if the game is over
  • to read user input command, i.e. the coordinates, to which to drip water.

By using (calling) the above functions, we can keep the main function rather short and easy to read. Explore the main function. Is the code easy to understand?

The most difficult section in main function is probably the condition of while, the first part of which is the value of integer variable waterTank. What happens, if we use an integer variable in the place of a boolean value? Compiler makes an implicit type conversion (coersion) from boolean to integer. As well, we could write waterTank != 0 as the condition.

Next explore function initBoard

The library random comprises the class default_random_engine, which is one of the random number generators provided by the class. When you call the constructor of the random number generator class (i.e. create a random number generator object), you can give so called seed value as a parameter. If you do not give a seed value at this phase, you can give it later by calling the function seed, as has been done in the code of the waterdrop game. After giving a seed value, you must tell, which distribution you want to use. The library random provides several distributions, and we will use one of them called uniform_int_distribution. In addition, we must tell to the distribution the interval, from which the random numbers will be taken.

Random numbers generated by the above kind of random number generator are so called pseudorandom numbers. It means that the same seed value generates always the same series of random numbers. You can test this by executing the following lines of code several times:

default_random_engine gen(42);
uniform_int_distribution<int> distr(1, 100);
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;
std::cout << distr(gen) << std::endl;

Pseudorandom numbers are useful for testing purposes, because they allow you to repeat the same execution for several times. This requires only to know the seed value used.

The function initBoard asks for the seed value. If the user gives no seed value (but presses enter instead, when an empty string is the input), the random number generator will be initialized with the number read from computer clock. If the user gives a seed value, then it will be used. When the submission system of the course tests automatically such a program that uses random numbers, the program must be implemented such that automatic tester can choose the seed value.

The function stoi converts a string to an integer. We have discussed this earlier.

The game board is initialized such that new square objects are created in two nested for loops. The amount of water of a square object is a random number between 0-4. The created square objects are first stored to vector row (inner for loop). When row vector is full, it is stored to the vector describing the whole game board (outer for loop).

Initializing the game board by using random numbers is not necessarily the best solution, from the point of view of the usability of the game. It is possible to get such a game board that you can win by dripping just one droplet of water to the right position of the game board. If you like challenges, you can develop a better algorithm for initializing the game board with the possibility to choose the level of difficulty. (For example, the total amount of water must always be the same. Winning the game becomes more difficult at higher levels.)

Next explore function printBoard

Function printBoard prints the numbers in X and Y axels, empty spaces, and new lines. It calls printing function of square objects, when each square prints itself, i.e. prints the correct character based on the state of the square.

As the numbers of X and Y axels, only the remainder after division by ten is printed. In this way, we can use the same space (one character) for all numbers in the ASCII user interface. You can try to increase the size of the board, and see what happens.

Most error-prone property in the program is that the printable coordinates start from 1, but indexing a vector starts from 0. You can see the effect of the property in this function.

There would be a little less details in this function, if it were implemented without printing empty spaces. However, such a solution would make the ASCII user interface even more uncomfortable to use.

Why on earth the implementor of the function has used output stream as a parameter? Maybe the reason is that in some situation printing could be done to another output stream than std::cout. We will consider streams more precisely later.

Next explore function readCommandSuccesfully

This function makes only minimal checks for the validity of input values. It checks if the given coordinates belong to the interval defined previously.

Control-C quits the program.

Conclusions

The program is a good example both of storing objects to a vector and of object-oriented programming. The most essential logic of the game is implemented such that square objects communicate with each other, for example, by adding water to other squares.

While exploring the program we have learnt new concepts such as forward declaration of a class and using clause in defining a new type. In addition, we have seen how to handle objects through pointers.

The game does not act exactly in the same way as the original one (the game behind the link). Namely, in our game, when a waterdrop pops, it adds water to other drops immediately. In the original game, a popped waterdrop produces splashes that moves the faster the closer the other waterdrops lie. We will return to such implementation, after we have gained more knowledge.