Casual Mechanics
On habrahabr, attempts are periodically made to describe the game-making process from various sides - from the embodiment of 3D graphics to the creation of network protocols. These topics are certainly important, but rather narrow. In this article I will try to use a broader approach - I will consider the principle of creating a game engine for the so-called casual games. The described mechanics is quite suitable for creating all sorts of pacman, arkanoid, platformer, etc. The description of the process will be based on the example of a primitive scrolldown shooter (from nostalgic feelings for Zybex and Xevious ) - we fly across the field, knock meteorites. Tool - Qt.
Immediately make a reservation that there are no beauties and completeness in the code. Classes are primitive and repeat code, functions are not optimal, graphicsugly no, but it puffs and tosses and turns. This is the basis from which you can work further. Experienced programmers - flipping through a cup of something hot, beginning or pouring into the topic may give food for thought. Getting started.
In order to choose the right way to organize the program, you need to decide on the participants in the main cycle. In any casual (and most non-casual) game there are at least three of them:
Other points are also possible - animation manager, artificial intelligence, etc. For example, these three will be enough for us.
What kind of participants are they?
The gameplay clock is tied to a timer ... um ... the gameplay clock. It controls the movement of objects on the playing field. Its main purpose is to ensure the integrity of the gameplay and its identity . This is very important - not only so that the speed of the game does not depend on the performance of the computer, but also to ensure synchronization when playing on the network.
Continuity simulator- These are auxiliary functions, the main purpose of which is to ensure that something important does not happen between calls to the game generator. For example, consider such a game moment:
Two consecutive calls of the game generator clock are shown on the left and right. Suppose that the speed of the yellow circle = 3. The distance between the circle and the rectangle, as can be seen from the figure, = 2. It turns out that the circle and the rectangle will not collide if they are not helped. This is what the continuity simulator provides.
Scene rendering - everything seems to be clear here. It is a separate item, since:
Immediately it occurred to me to create a separate stream for each of the participants. However, this approach is not optimal, since:
Therefore, we will do the threads, but in a slightly different way - a separate thread for processing messages from the OS (rendering, keyboard polling), and a separate thread for the game process, in which all three participants are called.
With the rendering and polling of the keyboard, everything is clear - it's just a stream of the main form of the application. We will deal with the flow of gameplay.
The structure of the stream is shown in the figure:
Now a little code with explanations.
First, set the frequencies. Roughly speaking, how many ms should pass between calls to processing logic, rendering:
And here is the main loop code - with a call to all participants, time checks, etc.
(note - if you look at the source code, everything is a bit more complicated there. Frames are being counted per second, information is displayed, etc.)
Surely the question arose - why do the rendering and continuity simulator functions need to know the time that has passed since the last update of the game logic? Everything is simple - in order to calculate the instantaneous state of the scene, and correctly process it and display it on the screen. To save resources, calling the continuity simulator can also transfer the time of its last call.
In our example, there are three kinds of objects:
The corresponding classes are made for them (CShip, CBullet, CMeteorite). For bullets and meteorites, QVector storage containers are specified.
To process user input, an array of “directions of movement” was created and the keyReleaseEvent and keyPressEvent functions were redefined:
keyReleaseEvent checks to see if there is a released key in the array of pressed keys and deletes it if any.
keyPressEvent accordingly puts the pressed key into the array of pressed keys (if it is not there). Processing of this array occurs in the function of the clock generator of the game process. There are moving objects of the game, the calculation of inertia during the movement of the ship, creating meteorites:
The CheckGameRules function checks the game rules - who crashed into someone, who went beyond what, and so on. By the way, in 2D this is all very conveniently done by the functions of the QPolygon, QRect classes and others like them.
Accordingly, the call of the simulator of continuity is simple to disgrace. With just a small step, we check the game logic:
Rendering draws the playing field and calls Draw () of all objects with the parameter of the current indentation from the last call of the game generator clock. Plus output of service information:
Actually, the rest is trivial programming. The application skeleton is disassembled, and implementation details can be found in the attached source code. As a result - the appearance of what I did:
Sources here . We fly with arrows, shoot with a space.
Sources on github .
Immediately make a reservation that there are no beauties and completeness in the code. Classes are primitive and repeat code, functions are not optimal, graphics
Application cycle
In order to choose the right way to organize the program, you need to decide on the participants in the main cycle. In any casual (and most non-casual) game there are at least three of them:
- Gameplay Clock
- Continuity simulator
- Scene rendering
Other points are also possible - animation manager, artificial intelligence, etc. For example, these three will be enough for us.
What kind of participants are they?
The gameplay clock is tied to a timer ... um ... the gameplay clock. It controls the movement of objects on the playing field. Its main purpose is to ensure the integrity of the gameplay and its identity . This is very important - not only so that the speed of the game does not depend on the performance of the computer, but also to ensure synchronization when playing on the network.
Continuity simulator- These are auxiliary functions, the main purpose of which is to ensure that something important does not happen between calls to the game generator. For example, consider such a game moment:
Two consecutive calls of the game generator clock are shown on the left and right. Suppose that the speed of the yellow circle = 3. The distance between the circle and the rectangle, as can be seen from the figure, = 2. It turns out that the circle and the rectangle will not collide if they are not helped. This is what the continuity simulator provides.
Scene rendering - everything seems to be clear here. It is a separate item, since:
- should not depend on the speed of the gameplay;
- should ensure smoothness of the image (at the speed of the object = 10 points on the screen and the frequency of the game = 30, jerks of a moving object will be visible if the frames are rendered only at the moment the game generator is called)
Possible ways to organize loops
Immediately it occurred to me to create a separate stream for each of the participants. However, this approach is not optimal, since:
- does not provide default synchronization between the participants in the loop. Suddenly you have to sacrifice rendering and animation in order to maintain the synchronism of network battles?
- significantly complicates the development and as a result increases the number of errors. Different threads = different resources, synchronization and sharing issues, and other delights of multithreaded programs.
Therefore, we will do the threads, but in a slightly different way - a separate thread for processing messages from the OS (rendering, keyboard polling), and a separate thread for the game process, in which all three participants are called.
With the rendering and polling of the keyboard, everything is clear - it's just a stream of the main form of the application. We will deal with the flow of gameplay.
Game Stream
The structure of the stream is shown in the figure:
Now a little code with explanations.
First, set the frequencies. Roughly speaking, how many ms should pass between calls to processing logic, rendering:
Copy Source | Copy HTML
- // Clock: FREQ - logic, FPS - rendering
- const int FREQ = 1000/40; // 1000 / FPS
- const int MAX_FPS = 1000/180;
And here is the main loop code - with a call to all participants, time checks, etc.
Copy Source | Copy HTML
- while (true)
- {
- qint64 time_cur_tick = QDateTime :: currentMSecsSinceEpoch ();
- int numLoops = 0;
- bool ft = true;
- while (time_prev_tick <time_cur_tick && numLoops <MAX_LOOPS)
- {
- // Call Logic
- w-> UpdateLogic (1 / FREQ);
- numLoops ++;
- if (ft)
- {
- ft = false;
- last_freq = time_cur_tick;
- }
- time_prev_tick + = FREQ;
- // Update time_cur_tick for more accurate timing
- time_cur_tick = QDateTime :: currentMSecsSinceEpoch ();
- }
- time_tmp = QDateTime :: currentMSecsSinceEpoch ();
- w-> SimulateConsistLogic ((float) (time_tmp - last_freq) / FREQ);
- time_tmp = QDateTime :: currentMSecsSinceEpoch ();
- if (time_tmp - time_lastrender> = MAX_FPS &&
w-> paint_mx.tryLock ())- {
- time_lastrender = time_tmp;
- float freq_bit = 0;
- if (time_tmp! = last_freq)
- freq_bit = (float) (time_tmp - last_freq) / FREQ;
- emit signalGUI (freq_bit);
- w-> paint_mx.unlock ();
- }
- }
(note - if you look at the source code, everything is a bit more complicated there. Frames are being counted per second, information is displayed, etc.)
Surely the question arose - why do the rendering and continuity simulator functions need to know the time that has passed since the last update of the game logic? Everything is simple - in order to calculate the instantaneous state of the scene, and correctly process it and display it on the screen. To save resources, calling the continuity simulator can also transfer the time of its last call.
How does it all work
In our example, there are three kinds of objects:
- player ship
- bullets
- meteorites
The corresponding classes are made for them (CShip, CBullet, CMeteorite). For bullets and meteorites, QVector storage containers are specified.
To process user input, an array of “directions of movement” was created and the keyReleaseEvent and keyPressEvent functions were redefined:
keyReleaseEvent checks to see if there is a released key in the array of pressed keys and deletes it if any.
keyPressEvent accordingly puts the pressed key into the array of pressed keys (if it is not there). Processing of this array occurs in the function of the clock generator of the game process. There are moving objects of the game, the calculation of inertia during the movement of the ship, creating meteorites:
Copy Source | Copy HTML
- void MainWindow :: UpdateLogic (float ftime)
- {
- float speed = 2;
- for (int i = 0; i <m_dir.size (); i ++)
- {
- if (m_dir [i] == MainWindow :: UP)
- actor1.adjustDirection (QVector2D (0, -speed));
- if (m_dir [i] == MainWindow :: DOWN)
- actor1.adjustDirection (QVector2D (0, speed));
- if (m_dir [i] == MainWindow :: LEFT)
- actor1.adjustDirection (QVector2D (-speed, 0));
- if (m_dir [i] == MainWindow :: RIGHT)
- actor1.adjustDirection (QVector2D (speed, 0));
- if (m_dir [i] == MainWindow :: SPACE &&
m_allowbullet == 0)- {
- m_bullets.push_back (CBullet (actor1.getX (), actor1.getY () - 1, QVector2D (0, -15)));
- qDebug (QString ("Added bullet. Pos% 1"). arg (m_bullets.size () - 1) .toAscii ());
- m_allowbullet = 5;
- fired ++;
- }
- }
- actor1.stepDirection ();
- bool dir_touched = false;
- for (int i = 0; i <m_dir.size (); i ++)
- {
- if (m_dir [i]! = MainWindow :: SPACE)
- {
- dir_touched = true;
- break;
- }
- }
- if (! dir_touched)
- {
- m_allowmove = 0;
- float inertia = 0.5;
- if (actor1.getSpeed () <0.5)
- inertia = 1;
- actor1.adjustSpeed (inertia);
- }
- for (int i = 0; i <m_bullets.size (); i ++)
- m_bullets [i] .stepDirection ();
- for (int x = 0; x <m_enemies1.size (); x ++)
- m_enemies1 [x] .stepDirection ();
- CheckGameRules ();
- if (m_enemies1.size () <max_enemies)
- {
- CMeteorite meteo (mrand (field_ident + CMeteorite :: meteo_size,
field_ident + field_w - CMeteorite :: meteo_size),- -mrand (0, 20),
- QVector2D (0, 1));
- while (true)
- {
- int i = 0;
- while (i <m_enemies1.size ())
- {
- if (meteo.getBoundsT (). intersects (m_enemies1 [i] .getBoundsT ()))
- break;
- i ++;
- }
- if (i == m_enemies1.size ())
- break;
- meteo = CMeteorite (mrand (1, 100), -mrand (0, 20),
- QVector2D (0, 1));
- }
- m_enemies1.push_back (meteo);
- }
- UpdateBullet ();
- }
The CheckGameRules function checks the game rules - who crashed into someone, who went beyond what, and so on. By the way, in 2D this is all very conveniently done by the functions of the QPolygon, QRect classes and others like them.
Copy Source | Copy HTML
- void MainWindow :: CheckGameRules (const float ftime)
- {
- QRect field_rect (field_ident, field_ident,
field_w,
field_h);- for (int i = 0; i <m_bullets.size (); i ++)
- {
- CBullet blt = m_bullets [i];
- float tx = 0, ty = 0;
- blt.getTickCoords (ftime, tx, ty);
- blt.setX (tx);
- blt.setY (ty);
- if (! field_rect.contains (m_bullets [i] .getX (), m_bullets [i] .getY ()))
- {
- m_bullets.remove (i--);
- }
- else
- {
- for (int j = 0; j <m_enemies1.size (); j ++)
- {
- CMeteorite enm = m_enemies1 [j];
- float etx = 0, ety = 0;
- enm.getTickCoords (ftime, etx, ety);
- enm.setX (etx);
- enm.setY (ety);
- if (blt.checkCollision (enm.getBodyT ()))
- {
- m_enemies1.remove (j--);
- m_bullets.remove (i--);
- score ++;
- break;
- }
- } // for
- }
- }
- for (int j = 0; j <m_enemies1.size (); j ++)
- {
- CMeteorite enm = m_enemies1 [j];
- if (! field_rect.contains (enm.getBoundsT ()) &&
- field_rect.bottomRight (). y () <enm.getBoundsT (). topLeft (). y ())
- {
- m_enemies1.remove (j--);
- }
- if (actor1.checkCollision (enm.getBodyT ()))
- {
- m_enemies1.remove (j--);
- hits ++;
- }
- }
- if (! field_rect.contains (actor1.getBoundsT (), true))
- {
- while (field_rect.x ()> = actor1.getBoundsT (). left ())
- actor1.setX (actor1.getX () + 1);
- while (field_rect.x () * 2 + field_rect.width () <= actor1.getBoundsT (). x () + actor1.getBoundsT (). width ())
- actor1.setX (actor1.getX () - 1);
- while (field_rect.top ()> = actor1.getBoundsT (). top ())
- actor1.setY (actor1.getY () + 1);
- while (field_rect.y () * 2 + field_rect.height () <= actor1.getBoundsT (). y () + actor1.getBoundsT (). height ())
- actor1.setY (actor1.getY () - 1);
- actor1.stop ();
- }
- }
Accordingly, the call of the simulator of continuity is simple to disgrace. With just a small step, we check the game logic:
Copy Source | Copy HTML
- void MainWindow :: SimulateConsistLogic (float ftime)
- {
- for (float bt = 0; bt <ftime; bt = bt + 0.1)
- {
- CheckGameRules (bt);
- }
- }
Rendering draws the playing field and calls Draw () of all objects with the parameter of the current indentation from the last call of the game generator clock. Plus output of service information:
Copy Source | Copy HTML
- void MainWindow :: Render ()
- {
- QPainter qpainter (this);
- const int bgw = 2;
- qpainter.setPen (QPen (Qt :: black, bgw));
- qpainter.setBrush (QBrush (Qt :: darkGray));
- qpainter.drawRect (field_ident, field_ident,
field_w + field_ident,
field_h + field_ident);- for (int i = 0; i <m_bullets.size (); i ++)
- {
- CBullet blt = m_bullets [i];
- blt.Draw (qpainter, freq_bit);
- }
- for (int i = 0; i <m_enemies1.size (); i ++)
- {
- CMeteorite enm = m_enemies1 [i];
- enm.Draw (qpainter, freq_bit);
- }
- actor1.Draw (qpainter, freq_bit);
- QPalette pal;
- qpainter.setBrush (pal.brush (QPalette :: Window));
- qpainter.setPen (QPen (pal.color (QPalette :: Window), 1));
- qpainter.drawRect (field_ident - bgw / 2, 0,
field_w + field_ident + bgw / 2,
field_ident - bgw);- qpainter.setPen (QPen (Qt :: black, bgw));
- qpainter.setBrush (QBrush (Qt :: darkGray, Qt :: NoBrush));
- qpainter.drawRect (field_ident, field_ident,
field_w + field_ident,
field_h + field_ident);- ui-> label_freq-> setText (QString ("% 1"). arg (freq) .toAscii ());
- ui-> label_fps-> setText (QString ("% 1"). arg (fps) .toAscii ());
- ui-> label_speed-> setText (QString ("% 1"). arg (actor1.getSpeed ()) .toAscii ());
- ui-> label_score-> setText (QString ("% 1"). arg (score) .toAscii ());
- ui-> label_fired-> setText (QString ("% 1"). arg (fired) .toAscii ());
- ui-> label_hits-> setText (QString ("% 1"). arg (hits) .toAscii ());
- }
Actually, the rest is trivial programming. The application skeleton is disassembled, and implementation details can be found in the attached source code. As a result - the appearance of what I did:
Sources here . We fly with arrows, shoot with a space.
Sources on github .