В этой статье речь пойдет о портировании игры Forest Tower Defense на телефон Nokia N9.
Я хорошо знаком с Qt, но плохо с QML, к счастью мне полностью удалось избежать его использования в приложении (да, это возможно!). Весь код в этой статье будет на C++, олдфаги одобряют.
Окно
Главное и единственное окно игры — это QGLWidget
class Widget : public QGLWidget, public Platform
{
Q_OBJECT
//...
Что такое Platform, и как проектировать кросплатформенные игры, можно почитать тут.
Графика
Все рисование происходит в QWidget::paintEvent
void Widget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
m_painter = &painter;
Application::instance().render();
flushFragments();
}
Хочу поделиться интересной находкой — методом QPainter::drawPixmapFragments (появился в Qt 4.7). Если все изображения хранятся в одном QPixmap'е, то достаточно создать массив объектов QPainter::PixmapFragment, каждый элемент которого будет содержать координаты, размеры, угол, масштаб, прозрачность и положение в исходном QPixmap'е. После этого делаем один вызов QPainter::drawPixmapFragments — и все фрагменты будут нарисованы:
void Widget::flushFragments()
{
m_painter->drawPixmapFragments(m_fragments.constData(), m_fragments.size(), pixmap);
m_fragments.clear();
}
Такой подход работает быстрее, чем отдельные вызовы QPainter::drawPixmap.
Полноэкранный режим
Для того, чтобы спрятать toolbar и statusbar, достаточно вызвать метод showFullScreen
int main(int argc, char** argv)
{
QApplication a(argc, argv);
QWidget w;
w.showFullscreen();
return a.exec();
}
Казалось бы, это очевидно, но я узнал об этом абсолютно случайно — заметил этот метод в документации и решил попробовать. Если вы будете искать в гугле что-то вроде «meego harmattan hide toolbar», то скорее всего найдете, как это сделать на QML, но не на С++. Будьте внимательны, у QWidget есть также метод showMaximized, который работает совсем не так, как showFullScreen.
Звук
QSound на Nokia N9 не работает, этот вариант отпадает. Phonon работает, но подходит только для проигрывания фоновой музыки. Для коротких и быстрых звуковых эффектов, которые могут накладываться друг на друга, нужно было найти что-нибудь другое. Мой выбор пал на QtGameEnabler — набор классов для разработки игр c Qt.
Разобрался я с ним довольно быстро, вот основные шаги для добавления в игру звуковых эффектов:
а) скопировать папку qtgameenabler в корень проекта, в .pro файле добавить строчку
include(qtgameenabler/qtgameenableraudio.pri)
б) создать объекты AudioMixer, AudioOut
GE::AudioMixer audioMixer;
GE::AudioOut audioOut(&audioMixer);
в) для каждого звука создать AudioBuffer
GE::AudioBuffer* appleHit = GE::AudioBuffer::loadOgg("/opt/foresttd-n9/assets/audio/appleHit.ogg");
GE::AudioBuffer* iceCrash = GE::AudioBuffer::loadOgg("/opt/foresttd-n9/assets/audio/iceCrash.ogg");
GE::AudioBuffer* arrowFly = GE::AudioBuffer::loadOgg("/opt/foresttd-n9/assets/audio/arrowFly.ogg");
г) каждый раз, когда мы хотим проиграть звук, создать объект AudioBufferPlayInstance и добавить его в AudioMixer
GE::AudioBufferPlayInstance* playOneTime = new GE::AudioBufferPlayInstance(appleHit);
playOneTime->setDestroyWhenFinished(true);
audioMixer.addAudioSource(playOneTime);
Бывают случаи, когда одновременно проигрывается больше 10 звуков, в результате получается неприятная кака каша. Простого способа побороть это я не нашел, но придумал достаточно элегантный выход — я унаследовался от AudioBufferPlayInstance и создал глобальный счетчик текущих звуков:
class SoundInstance : public GE::AudioBufferPlayInstance
{
public:
SoundInstance(GE::AudioBuffer* buffer)
: GE::AudioBufferPlayInstance(buffer)
{
++ms_count;
}
virtual ~SoundInstance()
{
--ms_count;
}
static int count()
{
return ms_count;
}
private:
static int ms_count;
};
int SoundInstance::ms_count = 0;
Перед проигрыванием каждого звука счетчик увеличивается, после — уменьшается. Таким образом я могу контролировать количество одновременно проигрываемых звуков:
if (SoundInstance::count() setDestroyWhenFinished(true);
audioMixer.addAudioSource(playOneTime);
}
Спасибо разработчикам QtGameEnabler, что сделали деструктор AudioBufferPlayInstance виртуальным, а также за метод setDestroyWhenFinished! И еще за то, что свежая версия из транка поддерживает формат ogg!
Еще один очень важный момент: для того, чтобы в игре можно было изменять громкость с помощью аппаратных клавиш, необходимо:
а) в .pro файл добавить
# Classify the application as a game to support volume keys on Harmattan
gameclassify.files += qtc_packaging/debian_harmattan/$${TARGET}.conf
gameclassify.path = /usr/share/policy/etc/syspart.conf.d
INSTALLS += gameclassify
б) В файле qtc_packaging/debian_harmattan/your_target_name.conf должно быть
[classify gaming]
/opt/usr/bin/your_target_name
Обработка ввода
Поскольку я не использовал multi-touch, мне хватило переопределить QWidget::mousePressEvent, QWidget::mouseMoveEvent и QWidget::mouseReleaseEvent
void Widget::mousePressEvent(QMouseEvent *e)
{
QGLWidget::mousePressEvent(e);
float y = height() - e->y();
m_mouseDown = true;
Application::instance().touch(e->x(), y);
}
void Widget::mouseReleaseEvent(QMouseEvent *e)
{
QGLWidget::mouseReleaseEvent(e);
float y = height() - e->y();
m_mouseDown = false;
Application::instance().release(e->x(), y);
}
void Widget::mouseMoveEvent(QMouseEvent *e)
{
QGLWidget::mouseMoveEvent(e);
if (m_mouseDown)
{
float y = height() - e->y();
Application::instance().drag(e->x(), y);
}
}
Обратите внимание на стоку «float y = height() — e->y()». Она для того, чтобы переводить значения «y» из системы коортинат QWidget в Декартову.
Игровой цикл
Для реализации простейшего игрового цикла я сделал следующее:
а) переопределил QObject::timerEvent
void Widget::timerEvent(QTimerEvent *)
{
Application::instance().simulate(); // game logic
update(); // repaint
}
б) при старте игры
m_timerId = startTimer(0);
в) при переходе игры в состояние паузы
killTimer(m_timerId);
Пауза
Когда пользователь сворачивает игру, необходимо перейти в режим «паузы»:
a) переопределить QObject::eventFilter
bool Widget::eventFilter(QObject* object, QEvent* event)
{
if (event->type() == QEvent::ActivationChange)
{
if (isActiveWindow())
{
resume(); // start timers, resume music
}
else
{
pause(); // kill timers, pause music
}
}
return QWidget::eventFilter(object, event);
}
б) установить фильтр событий
int main(int argc, char** argv)
{
QApplication a(argc, argv);
Widget w;
w.show();
a.installEventFilter(&w);
return a.exec();
}
Когда я впервые отправил игру на модерацию в Ovi Store, QA обнаружили баг — приложение не разворачивалось, если его свернуть и попытаться запустить заново нажатием на .desktop иконку:The application cannot back to front via clicking application icon after suspending the application in background.
Я был очень удивлен таким поведением, игра нормально разворачивалась из экрана запущеных приложений (Open Apps Screen), но при нажатии на иконку ничего не происходило. Поиск решения этой проблемы был неудачен, все что удалось найти — это MeeGo Harmattan application lifecycle, но увы, опять QML. Stackoverflow молчал, developer.support@nokia.com тоже, пришлось самому искать выход. Я решил проверить, приходят ли какие-нибудь события в QWidget::eventFilter при нажатии на иконку:
bool Widget::eventFilter(QObject *object, QEvent *event)
{
if (event->type() == QEvent::ActivationChange)
{
if (isActiveWindow())
{
resume();
}
else
{
pause();
}
}
qDebug() <type();
return QWidget::eventFilter(object, event);
}
Оказалось, приходит событие с кодом 50. Посмотрел, что это за событие:QEvent::SockAct — Socket activated, used to implement QSocketNotifier.
WTF? Какой сокет?! Ведь моя игра не выходит в интернет. Знающие, подскажите, пожалуйста. Самое смешное — это единственное событие, за которое я мог зацепиться, что я и сделал:
bool Widget::eventFilter(QObject *object, QEvent *event)
{
if (event->type() == QEvent::ActivationChange)
{
if (isActiveWindow())
{
resume();
}
else
{
pause();
}
}
else if (event->type() == QEvent::SockAct) // pure magic
{
activateWindow();
resume();
}
return QWidget::eventFilter(object, event);
}
Вибро
Для добавления вибро-эффектов нужно:
а) добавить следующие строчки в .pro файл
CONFIG += mobility
MOBILITY += feedback
б) подключить необходимый заголовочный файл
#include
в) создать объект эффекта
QFeedbackHapticsEffect vibro;
vibro.setAttackIntensity(0.0);
vibro.setAttackTime(40);
vibro.setIntensity(0.5);
vibro.setDuration(80);
vibro.setFadeTime(40);
vibro.setFadeIntensity(0.0);
г) в нужный момент вызвать
vibro.start();
Тут вроде все просто, но изначально я хотел добиться такого вибро эффекта, как при нажатии на кнопки виртуальной клавиатуры в Nokia N9. Если кто-то знает, как это сделать — поделитесь, пожалуйста.
Ресурсы
Мне не хотелось упаковывать ресурсы в .rc файл, потому как папка с ресурсами у меня общая для Android/Desktop/MeeGo и находится она на два уровня выше, в то время как QtCreator требует, чтобы все ресурсы находились внутри папки с проектом (немного странное требование, не находите?)
Поэтому я пошел по другому пути и добавил правило для копирования ресурсов в .pro файл:
assetsDir.source = ../../assets
DEPLOYMENTFOLDERS = assetsDir
installPrefix = /opt/$${TARGET}
for(deploymentfolder, DEPLOYMENTFOLDERS) {
item = item$${deploymentfolder}
itemfiles = $${item}.files
$$itemfiles = $$eval($${deploymentfolder}.source)
itempath = $${item}.path
$$itempath = $${installPrefix}/$$eval($${deploymentfolder}.target)
export($$itemfiles)
export($$itempath)
INSTALLS += $$item
}
Теперь все файлы и папки, которые находились в ../../assets, будут скопированы на N9 при установке и будут доступны по адресу /opt/foresttd-n9/assets
Иконка
Cделать иконку в стиле MeeGo Harmattan легко, если воспользоваться файлом-заготовкой отсюда (Icon Templates)
Результат
Приложение уже появилось в Ovi Store, стоимость его 2EUR. Хочется отметить, что это единственная tower defense игра для Nokia N9 в Ovi Store. Процесс QA прошел довольно быстро — игра была на рассмотрении 2 дня и еще 1 день после того, как я пофиксил баг. Бесплатное тестирование вашего приложения инженерами из Nokia — положительный момент.
Хочу выразить благодарность компании Nokia за N9, телефон очень классный.
Также благодарю всех хабравчан за многочисленные багрепорты, отзывы и идеи по улучшению бесплатной версии игры под андроид, на маркете вы сможете найти обновление с новыми уровнями.