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
.
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.
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.