Механіка казуальних ігор
На хабрахабрі періодично робляться спроби опису процесу ігророблення з різних сторін — від втілення 3D-графіки до створення мережевих протоколів. Ці теми, безперечно, важливі, проте досить вузькі. У цій статті я спробую використовувати ширший підхід - розгляну принцип створення ігрового движка для т.зв. казуальних ігор. Описувана механіка цілком підійде для створення всіляких пакманів, арканоїдів, платформерів та ін. Опис процесу буде на прикладі примітивного scrolldown шутера (з ностальгічних почуттів до Zybex і Xevious) - літаємо по полю, збиваємо метеорити. Інструмент Qt. Одразу обмовлюся, що жодних краси та закінченості в коді немає. Класи примітивні і повторюють код, функції неоптимальні, графіка некрасива ніяка, зате це все пихкає і повертається. Це база, з якою можна працювати далі. Досвідченим програмістам - перегорнути за чашкою чогось гарячого, початківцям або тим, хто вливається в тему, можливо дасть їжу для роздумів. Починаємо.
Цикл програми
Щоб правильно вибрати спосіб організації програми, потрібно визначитися з учасниками головного циклу. У будь-якій казуальній (і більшості неказуальних) грі їх щонайменше три:
- Тактовий генератор ігрового процесу
- Симулятор безперервності
- Рендеринг сцени
Що це за учасники?
Тактовий генератор ігрового процесу- це прив'язаний до таймера ... ем ... тактовий генератор ігрового процесу. У ньому контролюються переміщення об'єктів ігровим полем. Основне його призначення - забезпечення цілісності ігрового процесу та йогооднаковість. Це дуже важливо – не тільки длящоб швидкість гри не залежала від продуктивності комп'ютера, але ще й для забезпечення синхронізації при грі по мережі.
Симулятор безперервності- це допоміжні функції, основне призначення яких - стежити, щоб між викликами генератора ігрового процесу не відбулося щось важливе. Наприклад, розглянемо такий ігровий момент:
Ліворуч і праворуч зображено два послідовні виклики тактового генератора ігрового процесу. Припустимо, що швидкість жовтого кола = 3. Відстань між колом та прямокутником, як видно з малюнка, = 2. Виходить, що коло і прямокутник так і не зіткнуться, якщо їм не допомогти. Цю допомогу надає симулятор безперервності.
Рендеринг сцени— тут начебто все зрозуміло. Він є окремим пунктом, оскільки:
- не повинен залежати від швидкості ігрового процесу;
- повинен забезпечити плавність зображення.
Можливі способи організації циклів
Відразу на думку спадає думка зробити окремим потоком для кожного з учасників. Однак такий підхід не є оптимальним, оскільки:
- не забезпечує за промовчанням синхронізацію між учасниками циклу. Раптом доведеться жертвувати рендерингом та анімацією заради підтримки синхронності мережевої баталії?
- значно ускладнює розробку та як наслідок підвищує кількість помилок. Різні потоки = різні ресурси, питання синхронізації та спільного доступу та інші принади багатопоточних програм.
Потік ігрового процесу
Структура потоку показано малюнку:
Тепер трохи коду з поясненнями. Для початку встановлюємо частоти. Грубо кажучи, скільки мс має пройти між викликами обробки логіки, рендерингу:
Copy Source Copy HTML
- // Тактовий генератор: FREQ – логіка, FPS – рендеринг
- const int FREQ = 1000/40; // 1000 / FPS
- const int MAX_FPS = 1000/180;
А ось код основного циклу — із викликом усіх учасників, перевірками за часом та ін.
Copy Source Copy HTML
- while (true)
- qint64 time_cur_tick = QDateTime::currentMSecsSinceEpoch();
- int numLoops = 0;
- bool ft = true;
- while (time_prev_tick)
- // Виклик логіки
- w->UpdateLogic( 1 / FREQ );
- numLoops++;
- if (ft)
- ft = false;
- last_freq = time_cur_tick;
- >
- time_prev_tick + = FREQ;
- // Оновлюємо time_cur_tick для точного тактування
- 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;
- emitsignalGUI( freq_bit );
- w->paint_mx.unlock();
- >
- >
(прим. - якщо будете дивитися вихідний код - там все дещо складніше. Іде підрахунок кадрів за секунду, виведення дебажної інформації та інше)
Напевно постало питання — навіщо функціям рендерингу та симулятора безперервності знати час, який минув з моменту останнього оновлення ігрової логіки? Все просто - для того, щоб розрахувати моментальний стан сцени, і правильно його обробити і вивести на екран. Для економії ресурсів викликаючи симулятор безперервності можна також передавати час його минулого виклику.
Як все це працює
Copy Source Copy HTML
- vo > float ftime)
- float speed = 2;
- for ( int i = 0; i ); i ++ )
- if ( m_dir[i] == MainWindow::UP )
- actor1.adjust Direction (QVector2D(0, -speed));
- if ( m_dir[i] == MainWindow::DOWN )
- actor1.adjust Direction (QVector2D(0, speed));
- if ( m_dir[i] == MainWindow::LEFT )
- actor1.adjust Direction (QVector2D(-speed, 0));
- if ( m_dir[i] == MainWindow::RIGHT )
- actor1.adjust Direction (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.step Direction ();
- bool dir_touched = false;
- for ( int i = 0; i ); i ++ )
- if ( m_dir[i] != MainWindow::SPACE )
- dir_touched = true;
- break;
- >
- >
- якщо (!dir_touched)
- m_allowmove= 0 ;
- інерція поплавця = 0 . 5 ;
- if ( actor1.getSpeed( ) 0 . 5 )
- інерція = 1;
- actor1.adjustSpeed(інерція);
- >
- for ( int i = 0; i ); я ++)
- m_bullets[ i ].step Direction ();
- for ( int x = 0; x ); x++ )
- m_enemies1[ x ].step Напрямок ();
- Перевірити правила гри();
- if ( m_enemies1.size( ) mrand( field_ident + CMeteorite::meteo_size, field_ident + field_w - CMeteorite::meteo_size ),
- -mrand(0, 20),
- QVector2D(0, 1));
- поки (правда)
- int i = 0;
- поки я ) )
- if( meteo.getBoundsT( ).intersects( m_enemies1[ i ].getBoundsT( ) ) )
- перерва;
- я ++;
- >
- if ( i == m_enemies1.size( ) )
- перерва;
- meteo = CMeteorite( mrand(1, 100), -mrand(0, 20),
- QVector2D(0, 1));
- >
- m_enemies1.push_back( meteo );
- >
- UpdateBullet();
- >
Функція CheckGameRules перевіряє правила гри — хто в кого врізався, хто за рамки чого вийшов і прочее. Кстати, у 2D це всеоченьзручно робиться функціями класів QPolygon, QRect і навіть з ними.
Копіювати вихідний код Копіювати HTML
- vo > const float ftime )
- QRect field_rect( field_ident, field_ident, field_w, field_h );
- for ( int i = 0; 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-- );
- >
- інше
- for ( int j = 0; j ); 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-- );
- оцінка++;
- перерва;
- >
- > //для
- >
- >
- for ( int j = 0; j ); j++ )
- CMeteorite enm = m_enemies1[ j ];
- if ( !field_rect.contains( enm.getBoundsT( ) ) &&
- field_rect. bottomRight ().y() topLeft ().y() )
- m_enemies1.remove(j--);
- >
- if ( actor1.checkCollision( enm.getBodyT( ) ) )
- m_enemies1.remove(j--);
- хіти++;
- >
- >
- 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 () 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 () height ())
- actor1.setY( actor1.getY( ) — 1);
- actor1.s top ();
- >
- >
Відповідно виклик симулятора безперервності прост до безобразия. Все лише з невеликим кроком перевіряємо ігрову логіку:
Рендеринг відрисовує ігрове поле та викликає Draw() усіх об’єктів з параметром поточного відступу від останнього виклику тактового генератора ігрового процесу. Плюс висновок службової інформації:
Копіювати вихідний код Копіювати HTML
- vo >це );
- 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 ); я ++)
- CBullet blt = m_bullets[ i ];
- blt.Draw(qpainter, freq_bit);
- >
- for ( int i = 0; 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( оцінка).toAscii() );
- ui->label_fired->setText( QString("%1" ).arg( запускається).toAscii() );
- ui->label_hits->setText( QString("%1" ).arg( hits ).toAscii() );
- >
Собственно, остальное — тривиальное программирование. Скелет додатків розібраний, а деталі реалізації можна переглянути в прикладених вихідних кодах. В якості підсумку — зовнішній вигляд того, що у мене вийшло: Ісходники тут. Летаем стрелками, стреляем пробелом. Вихідники на гітхабі.
Хардкорна конфа за С++. Ми запрошуємо лише профі.