Pull to refresh

Механика казуальных игр

Reading time 16 min
Views 14K
На хабрахабре периодически предпринимаются попытки описания процесса игроделания с самых разных сторон — от воплощения 3D-графики до создания сетевых протоколов. Эти темы, безусловно, важны, однако довольно узкие. В данной статье я попробую использовать более широкий подход — рассмотрю принцип создания игрового движка для т.н. казуальных игр. Описываемая механика вполне подойдет для создания всяческих пакманов, арканоидов, платформеров и пр. Описание процесса будет на примере примитивного scrolldown шутера (из ностальгических чувств к Zybex и Xevious) — летаем по полю, сбиваем метеориты. Инструмент — Qt.

Сразу оговорюсь, что никаких красот и законченности в коде нет. Классы примитивны и повторяют код, функции неоптимальны, графика некрасивая никакая, зато это всё пыхтит и ворочается. Это — база, с которой можно работать дальше. Опытным программистам — пролистать за чашечкой чего-нибудь горячего, начинающим или вливающимся в тему возможно даст пищу для размышления. Начинаем.

Цикл приложения


Для того, чтобы правильно выбрать способ организации программы, нужно определиться с участниками главного цикла. В любой казуальной (и большинстве неказуальных) игре их минимум три:
  • Тактовый генератор игрового процесса
  • Симулятор непрерывности
  • Рендеринг сцены

Возможны и другие пункты — менеджер анимации, искусственный интеллект и пр. Для примера нам вполне хватит этих трех.

Что это за такие участники?

Тактовый генератор игрового процесса — это привязанный к таймеру… эм… тактовый генератор игрового процесса. В нем контролируются перемещения объектов по игровому полю. Основное его назначение — обеспечение целостности игрового процесса и его одинаковость. Это очень важно — не только для того, чтобы скорость игры не зависела от производительности компьютера, но еще и для обеспечения синхронизации при игре по сети.

Симулятор непрерывности — это вспомогательные функции, основное назначение которых — следить, чтобы между вызовами генератора игрового процесса не произошло что-нибудь важное. Например, рассмотрим такой игровой момент:
image

Слева и справа изображены два последовательных вызова тактового генератора игрового процесса. Предположим, что скорость желтого круга = 3. Расстояние между кругом и прямоугольником, как видно из рисунка, = 2. Получается, что круг и прямоугольник так и не столкнутся, если им не помочь. Эту помощь и оказывает симулятор непрерывности.

Рендеринг сцены — тут вроде бы всё понятно. Он отдельным пунктом, так как:
  • не должен зависеть от скорости игрового процесса;
  • должен обеспечить плавность изображения (при скорости объекта = 10 точкам на экране и частоте игры = 30 будут видны рывки движущегося объекта, если рендерить кадры только в момент вызова генератора игрового процесса)

Возможные способы организации циклов


Сразу в голову приходит мысль сделать по отдельному потоку для каждого из участников. Однако такой подход не является оптимальным, так как:
  • не обеспечивает по умолчанию синхронизацию между участниками цикла. Вдруг придется жертвовать рендерингом и анимацией ради поддержания синхронности сетевой баталии?
  • значительно усложняет разработку и как следствие повышает количество ошибок. Разные потоки = разные ресурсы, вопросы синхронизации и совместного доступа, и прочие прелести многопоточных программ.

Поэтому потоки делать будем, но несколько иначе — отдельный поток на обработку сообщений от ОС (отрисовка, опрос клавиатуры), и отдельный поток на игровой процесс, в котором вызываются все три участника.
С отрисовкой и опросом клавиатуры всё понятно — это просто поток главной формы приложения. Разберемся с потоком игрового процесса.

Поток игрового процесса


Структура потока показана на рисунке:
image

Теперь немного кода с пояснениями.
Для начала устанавливаем частоты. Грубо говоря, сколько мс должно пройти между вызовами обработки логики, рендеринга:
Copy Source | Copy HTML
  1. // Тактовый генератор: FREQ — логика, FPS — рендеринг
  2. const int FREQ = 1000 / 40; // 1000 / FPS
  3. const int MAX_FPS = 1000 / 180;


А вот код основного цикла — с вызовом всех участников, проверками по времени и пр.
Copy Source | Copy HTML
  1. while (true)
  2. {
  3.     qint64 time_cur_tick = QDateTime::currentMSecsSinceEpoch();
  4.     int numLoops =  0;
  5.  
  6.     bool ft = true;
  7.     while ( time_prev_tick < time_cur_tick && numLoops < MAX_LOOPS )
  8.     {
  9.         // Вызов логики
  10.         w->UpdateLogic( 1 / FREQ );
  11.         numLoops++;
  12.  
  13.         if ( ft )
  14.         {
  15.             ft = false;
  16.             last_freq = time_cur_tick;
  17.         }
  18.         time_prev_tick += FREQ;
  19.  
  20.         // Обновляем time_cur_tick для более точного тактирования
  21.         time_cur_tick = QDateTime::currentMSecsSinceEpoch();
  22.     }
  23.  
  24.     time_tmp = QDateTime::currentMSecsSinceEpoch();
  25.     w->SimulateConsistLogic( (float)( time_tmp - last_freq )/FREQ );
  26.  
  27.     time_tmp = QDateTime::currentMSecsSinceEpoch();
  28.     if ( time_tmp - time_lastrender >= MAX_FPS &&
             w->paint_mx.tryLock() )
  29.     {
  30.         time_lastrender = time_tmp;
  31.         float freq_bit =  0;
  32.         if (  time_tmp != last_freq )
  33.             freq_bit = (float)( time_tmp - last_freq )/FREQ ;
  34.  
  35.         emit signalGUI( freq_bit );
  36.         w->paint_mx.unlock();
  37.     }
  38. }


(прим. — если будете смотреть исходный код — там всё несколько сложнее. Идет подсчет кадров в секунду, вывод дебажной информации и прочее)

Наверняка возник вопрос — зачем функциям рендеринга и симулятора непрерывности знать время, которое прошло с момента последнего обновления игровой логики? Всё просто — для того, чтобы рассчитать моментальное состояние сцены, и верным образом его обработать и вывести на экран. Для экономии ресурсов вызывая симулятор непрерывности можно передавать также время его прошлого вызова.

Как всё это работает


В нашем примере три вида объектов:
  • корабль игрока
  • пули
  • метеориты

Для них сделаны соответствующие классы (CShip, CBullet, CMeteorite). Для пуль и метеоритов заданы контейнеры QVector для хранения.
Для обработки пользовательского ввода создан массив «направлений движения» и переопределены функции keyReleaseEvent и keyPressEvent:
keyReleaseEvent проверяет, есть ли в массиве нажатых клавиш отпускаемая клавиша, и удаляет ее при наличии.
keyPressEvent соответственно заносит нажатую клавишу в массив нажатых клавиш (если ее там нет). Обработка этого массива происходит в функции тактового генератора игрового процесса. Там же происходят перемещения игровых объектов, обсчет инерции при движении корабля, создание метеоритов:
Copy Source | Copy HTML
  1. void MainWindow::UpdateLogic( float ftime )
  2. {
  3.     float speed = 2;
  4.     for ( int i = 0; i < m_dir.size(); i++ )
  5.     {
  6.         if ( m_dir[i] == MainWindow::UP )
  7.             actor1.adjustDirection( QVector2D( 0, -speed ) );
  8.         if ( m_dir[i] == MainWindow::DOWN )
  9.             actor1.adjustDirection( QVector2D( 0, speed ) );
  10.         if ( m_dir[i] == MainWindow::LEFT )
  11.             actor1.adjustDirection( QVector2D( -speed, 0 ) );
  12.         if ( m_dir[i] == MainWindow::RIGHT )
  13.             actor1.adjustDirection( QVector2D( speed, 0 ) );
  14.  
  15.         if ( m_dir[i] == MainWindow::SPACE &&
                 m_allowbullet == 0 )
  16.         {
  17.             m_bullets.push_back( CBullet( actor1.getX(), actor1.getY() — 1, QVector2D( 0, -15) ) );
  18.             qDebug( QString(«Added bullet. Pos %1»).arg( m_bullets.size() — 1 ).toAscii() );
  19.             m_allowbullet = 5;
  20.             fired++;
  21.         }
  22.     }
  23.     actor1.stepDirection();
  24.     bool dir_touched = false;
  25.     for ( int i = 0; i < m_dir.size(); i++ )
  26.     {
  27.         if (m_dir[i] != MainWindow::SPACE )
  28.         {
  29.             dir_touched = true;
  30.             break;
  31.         }
  32.     }
  33.  
  34.     if ( !dir_touched )
  35.     {
  36.         m_allowmove= 0;
  37.         float inertia =  0.5;
  38.         if ( actor1.getSpeed() <  0.5 )
  39.             inertia = 1;
  40.         actor1.adjustSpeed( inertia);
  41.     }
  42.  
  43.  
  44.     for ( int i = 0; i < m_bullets.size(); i++ )
  45.         m_bullets[ i ].stepDirection();
  46.  
  47.     for ( int x = 0; x < m_enemies1.size(); x++ )
  48.         m_enemies1[ x ].stepDirection();
  49.  
  50.     CheckGameRules();
  51.     if ( m_enemies1.size() < max_enemies )
  52.     {
  53.         CMeteorite meteo( mrand( field_ident + CMeteorite::meteo_size,
                                     field_ident + field_w - CMeteorite::meteo_size ),
  54.                           -mrand( 0, 20 ),
  55.                       QVector2D( 0, 1 ) );
  56.         while( true )
  57.         {
  58.             int i =  0;
  59.             while( i < m_enemies1.size() )
  60.             {
  61.                 if( meteo.getBoundsT().intersects( m_enemies1[ i ].getBoundsT() ) )
  62.                     break;
  63.  
  64.                 i++;
  65.             }
  66.  
  67.             if ( i == m_enemies1.size() )
  68.                 break;
  69.  
  70.             meteo = CMeteorite( mrand( 1, 100 ), -mrand( 0, 20 ),
  71.                                   QVector2D( 0, 1 ) );
  72.  
  73.         }
  74.         m_enemies1.push_back( meteo );
  75.     }
  76.     UpdateBullet();
  77. }

Функция CheckGameRules проверяет игровые правила — кто в кого врезался, кто за рамки чего вышел и прочее. Кстати, в 2D это всё очень удобно делается функциями классов QPolygon, QRect и иже с ними.

Copy Source | Copy HTML
  1. void MainWindow::CheckGameRules( const float ftime )
  2. {
  3.     QRect field_rect( field_ident, field_ident,
                          field_w,
                          field_h );
  4.  
  5.     for ( int i = 0; i < m_bullets.size(); i++ )
  6.     {
  7.         CBullet blt = m_bullets[ i ];
  8.         float tx =  0, ty =  0;
  9.         blt.getTickCoords( ftime, tx, ty );
  10.  
  11.         blt.setX( tx );
  12.         blt.setY( ty );
  13.  
  14.         if ( !field_rect.contains( m_bullets[ i ].getX(), m_bullets[ i ].getY() ) )
  15.         {
  16.             m_bullets.remove( i-- );
  17.         }
  18.         else
  19.         {
  20.             for ( int j = 0; j < m_enemies1.size(); j++ )
  21.             {
  22.                 CMeteorite enm = m_enemies1[ j ];
  23.                 float etx =  0, ety =  0;
  24.                 enm.getTickCoords( ftime, etx, ety );
  25.                 enm.setX( etx );
  26.                 enm.setY( ety );
  27.  
  28.                 if ( blt.checkCollision( enm.getBodyT() ) )
  29.                 {
  30.                     m_enemies1.remove( j-- );
  31.                     m_bullets.remove( i-- );
  32.                     score++;
  33.                     break;
  34.                 }
  35.             } //for
  36.         }
  37.     }
  38.  
  39.     for ( int j = 0; j < m_enemies1.size(); j++ )
  40.     {
  41.  
  42.         CMeteorite enm = m_enemies1[ j ];
  43.         if ( !field_rect.contains( enm.getBoundsT() ) &&
  44.              field_rect.bottomRight().y() < enm.getBoundsT().topLeft().y() )
  45.         {
  46.             m_enemies1.remove( j-- );
  47.         }
  48.  
  49.         if ( actor1.checkCollision( enm.getBodyT() ) )
  50.         {
  51.             m_enemies1.remove( j-- );
  52.             hits++;
  53.         }
  54.     }
  55.  
  56.  
  57.     if ( !field_rect.contains( actor1.getBoundsT(), true ) )
  58.     {
  59.         while ( field_rect.x() >= actor1.getBoundsT().left() )
  60.             actor1.setX( actor1.getX() + 1 );
  61.  
  62.         while ( field_rect.x()*2 + field_rect.width() <= actor1.getBoundsT().x() + actor1.getBoundsT().width())
  63.             actor1.setX( actor1.getX() — 1 );
  64.  
  65.         while ( field_rect.top() >= actor1.getBoundsT().top() )
  66.             actor1.setY( actor1.getY() + 1 );
  67.  
  68.         while ( field_rect.y()*2 + field_rect.height() <= actor1.getBoundsT().y() + actor1.getBoundsT().height())
  69.             actor1.setY( actor1.getY() — 1 );
  70.  
  71.         actor1.stop();
  72.  
  73.     }
  74. }


Соответственно вызов симулятора непрерывности прост до безобразия. Всего лишь с небольшим шагом проверяем игровую логику:
Copy Source | Copy HTML
  1. void MainWindow::SimulateConsistLogic( float ftime )
  2. {
  3.     for ( float bt = 0; bt < ftime; bt = bt + 0.1 )
  4.     {
  5.         CheckGameRules( bt );
  6.     }
  7. }


Рендеринг отрисовывает игровое поле и вызывает Draw() всех объектов с параметром текущего отступа от последнего вызова тактового генератора игрового процесса. Плюс вывод служебной информации:
Copy Source | Copy HTML
  1. void MainWindow::Render()
  2. {
  3.     QPainter qpainter(this);
  4.  
  5.     const int bgw = 2;
  6.     qpainter.setPen (QPen(Qt::black, bgw));
  7.     qpainter.setBrush( QBrush( Qt::darkGray ) );
  8.     qpainter.drawRect( field_ident, field_ident,
                           field_w + field_ident,
                           field_h + field_ident );
  9.  
  10.  
  11.     for ( int i = 0; i < m_bullets.size(); i++ )
  12.     {
  13.         CBullet blt = m_bullets[ i ];
  14.         blt.Draw( qpainter, freq_bit );
  15.     }
  16.  
  17.     for ( int i = 0; i < m_enemies1.size(); i++ )
  18.     {
  19.         CMeteorite enm = m_enemies1[ i ];
  20.         enm.Draw( qpainter, freq_bit );
  21.     }
  22.  
  23.     actor1.Draw( qpainter, freq_bit );
  24.  
  25.     QPalette pal;
  26.     qpainter.setBrush( pal.brush( QPalette::Window ) );
  27.     qpainter.setPen (QPen(pal.color( QPalette::Window), 1));
  28.     qpainter.drawRect( field_ident - bgw/2, 0,
                           field_w + field_ident + bgw/2,
                           field_ident - bgw );
  29.  
  30.     qpainter.setPen (QPen(Qt::black, bgw));
  31.     qpainter.setBrush( QBrush( Qt::darkGray, Qt::NoBrush ));
  32.     qpainter.drawRect( field_ident, field_ident,
                           field_w + field_ident,
                           field_h + field_ident );
  33.  
  34.     ui->label_freq->setText( QString("%1").arg( freq ).toAscii() );
  35.     ui->label_fps->setText( QString("%1").arg( fps ).toAscii() );
  36.     ui->label_speed->setText( QString("%1").arg( actor1.getSpeed() ).toAscii() );
  37.  
  38.     ui->label_score->setText( QString("%1").arg( score ).toAscii() );
  39.     ui->label_fired->setText( QString("%1").arg( fired ).toAscii() );
  40.     ui->label_hits->setText( QString("%1").arg( hits ).toAscii() );
  41.  
  42. }


Собственно, остальное — тривиальное программирование. Скелет приложения разобран, а детали реализации можно посмотреть в прилагаемых исходных кодах. В качестве итога — внешний вид того, что у меня получилось:
image
Исходники тут. Летаем стрелками, стреляем пробелом.
Исходники на гитхабе.
Tags:
Hubs:
+49
Comments 38
Comments Comments 38

Articles