Pull to refresh

QML — больше, чем просто GUI

Reading time 10 min
Views 19K
Этот пост участвует в конкурсе „Умные телефоны за умные посты“.

Пятница, как известно, — конец рабочей недели, лучшее время для развлечения и игр. А лучшие игры — это те, которые увлекательны, в которых надо немножко подумать, и в которые можно сыграть с друзьями. Одну из таких игр я и решил написать.

Этот пост не является очередным переводом или вольным изложением разнообразных QML Howto и Quick Start. Скорее, это описание подводных камней, с которыми можно столкнуться при написании реального приложения.

Игровое полеКогда Qt Quick/QML только было заявлено, от Нокии звучали слова, что «в перспективе не только пользовательский интерфейс будет писаться на Qt Quick, но и вся логика несложных приложений будет написана на яваскрипте, программистам не потребуется написать ни строчки кода на плюсах». Заявление было ещё более провокационное, чем мой заголовок, и сразу меня заинтересовало: я решил попробовать написать несложную игру без единой строчки кода на плюсах.

Чтобы подогреть интерес, добавлю, что:
  • обычно я код пишу как раз на плюсах
  • я достаточно слабо знаю JS
  • я не умею и ненавижу делать интерфейсы
  • когда-то я попытался сделать эту же игру на честном Qt, но сломался, не выдержав общения с QGraphicsScene и другими интересными классами
  • результат моих трудов можно не только скачать, но и сыграть в них по сети
  • все исходники можно скачать у меня из bazaar или тарболлом.


Об остальном мы узнаем под катом.


Версия первая, или война с драг-н-дропом

В качестве отправной точки была выбрана игра Цаар — настольная логическая игра для двух игроков. Я искренне надеюсь, что моя коварная реализация данной игры не вызовет негодования правообладателей, но тем не менее рекомендую заинтересовавшимся её купить — в живом виде она куда интереснее, чем в моём исполнении.

А мы возвращаемся к разработке. Первым делом я десять минут созерцал игровое поле (на рисунке сверху): во всех приличных играх оно, как известно, прямоугольное. По истечении десяти минут я сказал: «Ага!», дополнил картинку узлами так, чтобы образовалась прямоугольная сетка, после чего написал функцию для проверки, существует ли узел на самом деле.

Второй проблемой были картинки. Как я уже сказал, я ненавижу делать интерфейсы, а уж как я рисую, лучше вообще не задумываться. Генерить картинку в рантайме мне показалось ещё большей глупостью. Я вздохнул, открыл inkscape и попытался аккуратно нарисовать игровое поле прямыми линиями. На третьей линии я сдался, открыл получившийся .svg-файл в виме и, путём нехитрых вычислений и манипуляций с макросами, выпрямил имеющиеся линии и дорисовал недостающие. Аналогичным образом были созданы шесть картинок с изображениями фишек. Именно поэтому они такие кривые и непохожие на оригинал.

Я так подробно расписываю свои экзистенциальные метания и так мало привожу кода, чтобы прям сейчас добавить: это самые большие проблемы, с которыми я встретился в процессе. Это действительно так. Спозиционировать поле на форме, сгенерировать и расставить фишки, сделать самодельные кнопки и вывод статуса — нет проблем! Анимировать перемещение фишек — 2 строчки! Разрешать и запрещать игроку двигать различные фишки в зависимости от фазы хода — всё это в QML делается настолько элементарно, что я могу только предложить почитать официальный мануал с примерами. К окончанию работы, js-файл со всей основной логикой, включая пустые строки и комментарии, занимал аж 173 строки или 6 функций.

Ах нет, пожалуй, я вспомнил один момент, который меня изумил и вынудил написать костылик. Этот момент называется: drag'n'drop. Да, это звучит странно, но драг-н-дроп в чуть менее, чем полностью графическом тулките сделан хреново. «Таскать» можно, оказывается, не элемента, а MouseArea, который на нём лежит. Единственное, что мы можем — это определить, какими кнопками можно жмякать, и какие ограничения по координатам у нас есть. Нельзя, как при работе с системой, обработать событие «в меня чем-то кинули, что это», нельзя разрешить кидаться элементом только в определенные объекты. Можно только обработать события pressed и released. Дальше крутись как хочешь. А в примерах, если мне не изменяет память, такими глупостями вообще занимаются только со всякими Grid'ами и List'ами, никаких тебе произвольно спозиционированных элементов. Видимо из-за этого, кстати, сказать элементу «мне то, как тебя кинули, не понравилось, вернись на место», тоже нельзя. Я же говорю, разработчики только о разборе RSS думали.

Поэтому пришлось поступать следующим образом. Свойством элемента, очевидно, являются не его координаты на экране — x и y — а позиция на доске. Координаты высчитываются исходя из позиции. При событиях pressed и released мы запоминаем исходную позицию и вычисляем, в какую новую пытались кинуть элементом. После этого вызываем функцию, отвечающую за перемещение элемента. Если функция говорит нам, что перемещение невозможно, нам надо сделать с элементом что? Правильно, вернуть в исходную позицию. Смотрите внимательно за руками:
    if (
            (oX == nX && oY == nY) // Move to itself
            || board[nI] == null // or move to empty position
            || !canMove(oX, oY, nX, nY) // No way from the old position to the new
            ) {
        board[oI].posX = -1;
        board[oI].posY = -1;
        board[oI].posX = oX;
        board[oI].posY = oY;
        return false;
    }

Видите это присваивание минус единицы? Ага. Дело в том, что в QML, если присвоить свойству то значение, которое в нём уже лежит (oX и oY), то движок считает, что свойство не изменилось, и не пересчитывает всё, то что с ним связано, в нашем случае это абсолютные координаты на экране. Приходится присваивать некоторое заведомо отличающееся значение, и только потом исходное.
Сама реализация драг-н-дропа выглядит вот так:
            MouseArea {
...
                acceptedButtons: Qt.LeftButton
                // представляете, тащим мы MouseArea, а перетаскивается — фишка
                drag.target: tzaarItem
                // ограничиваем перемещение только игровым полем
                drag.minimumX: (tzaarItem.parent.width - tzaarItem.parent.paintedWidth)/2
                drag.maximumX: (tzaarItem.parent.width + tzaarItem.parent.paintedWidth)/2
                drag.minimumY: (tzaarItem.parent.height - tzaarItem.parent.paintedHeight)/2
                drag.maximumY: (tzaarItem.parent.height + tzaarItem.parent.paintedHeight)/2
                drag.axis:  Drag.XandYAxis
                // при начале драга мы фишку "поднимаем", чтобы её не перекрыло никакое поле
                onPressed: { tzaarItem.z = 10; }
                onReleased: {
                    // при дропе опускаем на место и определяем из её новых координат положение на доске
                    tzaarItem.z = 1;
                    var oldPosX = tzaarItem.posX;
                    var oldPosY = tzaarItem.posY;
                    var posX = tzaarItem.posXofX(tzaarItem.x);
                    var posY = tzaarItem.posYofY(tzaarItem.y);
                    tzaarItem.parent.movePiece(oldPosX, oldPosY, posX, posY);
                }
            }

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

Версия вторая, или сеть наносит ответный удар.

Где в этот момент я начал задумываться и о третьей проблеме, но пока до неё ещё не дошло, мы поговорим о сети.

Как я обнаружил, в QML очень бедная и грустная поддержка работы с сетью. У нас есть Ajax-запросы из javascript-движка (причём, только асинхронные), и у нас есть активно измусоленный во всех примерах XmlListModel. Мне не хотелось бы верить, что весь QML был создан исключительно для лёгкого разбора RSS-потоков.

Как бы то ни было, забегая вперёд, скажу, что самой наглядной иллюстрацией бедности работы с сетью в QML является следующая строчка:
    Component.onDestruction: disconnect(); // yes, it will never work, but I have to try

Если коротко, я хотел бы при закрытии игры отправлять на сервер сообщение, что я отключился и сессию можно убивать. Проблема вся в том, что при приходе сигнала я создаю асинхронный ajax-запрос, «отправляю» его, а дальше… а дальше наш цикл событий (event loop) успешно продолжает работу и штатно завершает работу программы — ведь причиной сигнала был нажатый крестик в углу окна. Вуаля! Запрос никогда на самом деле не успеет дойти до сервера. Никогда. Но я пытаюсь, я верю в лучшее.

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

Когда я решил использовать XMLHttpRequest, я сразу понял, что о прямых соединениях между клиентами можно забыть, мне нужен сервер. Для написания самого сервера волей-неволей пришлось забыть о QML/JS, но оно и к лучшему — сервер трудно назвать «несложным» приложением. Написал я его на окамле, и единственной причиной такого нелепого поступка был тот факт, что именно Ocsigen-сервер у меня доверчиво смотрит в мир 80-ым портом. Логика работы сервера довольно проста и описывается следующими фактами:
  • сервер принимает запросы от анонимов, генерит для них id сессии и либо подбирает партнёра из ожидающих (в идеале таковых может быть не больше одного), либо ставит в очередь ожидания партнёра
  • когда игра начинается, именно сервер теперь генерирует начальную расстановку фишек и отсылает её обоим игрокам
  • игрок отсылает сообщение о своём перемещении фишки, и оно отправляется его партнёру
  • раз в секунду каждый игрок запрашивает у сервера информацию о свежих действиях
  • если от игрока не приходило запросов в течение 3 секунд, он считается пропавшим без вести и игра обламывается
  • на любую возможную ошибку (некорректные данные, пропавший оппонент и т.п.) игроку отсылается ошибка, после которой клиент разрывает связь и выводит на экран некрасивое сообщение «Some error occured»
  • Клиент отсылает все данные в GET-полях (query string), а ответ приходит в бинарном виде

Пока желающие смотрят код сервера, я обращу ваше внимание на последний пункт. Чтобы не развлекаться на сервере с генерацией JSON, и чтобы не вгонять клиентов с плохим интернетом в тоску, я отправлял все данные посредством совершенно идиотски придуманного мною протокола. Это была моя большая ошибка, постарайтесь её не совершать. Первые 16 байт ответа всегда содержали в себе id сессии (обычные ascii-символы), а дальше кодом каждого байта было соответствующее значение. То есть, в ответе регулярно встречались символы \0, \1 и им подобные. Если бы я знал яваскрипт лучше, я бы догадался так не делать, но я уже сотворил глупость. Как выяснилось, яваскрипт не умеет в строках работать с байтами, только с символами. Непечатные символы он переварил, не задумываясь. Нулевой байт проглотил, даже не подавившись. Беда началась при отсылке сгенерированной доски — в какой-то момент индекс ячейки превышал 0x80 и умный яваскрипт считал этот байт куском следующего символа.

Я перерыл интернет, я обнаружил инструкцию — принудительно сменить кодировку ответа на x-custom-charset — я обнаружил, что js-движок в Qt это не позволяет, и сменил кодировку прямо на сервере. После этого я скачал готовую библиотеку BinaryReader.js, которая уверяла, что способна читать текст по байтам и попробовал читать результат ещё и ей. Всё было тщетно — JS упорно отдавал мне 0xff вместо моего байта. На своё счастье я обнаружил, что индексы всех разрешенных для фишек ячеек — чётные. Я стал делить их пополам при передаче, и это позволило читать данные, как положено. В итоге код оброс ещё всего одним компонентом на 170 строк, который взаимодействовал с сервером и позволял насладиться полноценной сетевой игрой, а я подошёл к последней проблеме ­— о ней сразу после рекламы.
Игра в процессе

Версия третья, или куда пихать?

В этот момент, наконец, давно обуревавшая меня мысль, наконец, оформилась. «Чувак, — сказала она, — а как бы нам код-то, того, скомпилировать?». Вопрос был актуальный — я весь код для тестого запускал через qmlviewer из qtcreator, но разумеется, это не выход. Хотелось сделать приложение доступ и более приземленным людям — у которых не стоит qmlviewer. И вот тут-то я получил удар поддых. Оказалось, что несмотря на наличие полностью QML-проектов в qtcreator, скомпилировать QML невозможно. Никак. Надо писать viewer на C++, и из него загружать основной QML-файл. Это, вообще говоря, большая беда, на мой взгляд. Помнится, когда-то, когда флэш стоял ещё далеко не на всех машинах, разработчики из Macromedia сделали очень хитрую и интересную вещь: в Flash Player с открытым swf-файлом можно было нажать кнопочку и «скомпилировать» самодостаточный exe-файл, который запускался уже на любой машине. Разработчикам из Нокии не помешало бы позаимствовать эту замечательную идею, с поправкой на разные архитектуры и платформы.

Ну ладно, подумал я, в конце концов весь код работает без плюсов, так и быть, пусть на плюсах будет проигрыватель. Я создал в qtcreator'е ещё один проект — на этот раз из категории «C++ и QML», и стал по автоматически сгенерированному примеру смотреть, как он компилируется. По результатам осмотра обнаружился интересный факт. В проекте примера QML-файлы складывались в каталог /qml/tzaar/. И в main.cpp была строчка:
viewer.setMainQmlFile(QLatin1String("qml/tzaar/main.qml"));

А вот в .pro-файле были интересные строки:
# Add more folders to ship with the application, here
folder_01.source = qml/tzaar
folder_01.target = qml
DEPLOYMENTFOLDERS = folder_01

Означали они ни много, ни мало, а то, что содержимое /qml/tzaar/ при установке программы копируется в /qml/. Улавливаете? Код примера был валидным ровно до того момента, как я захотел бы его куда-нибудь установить, после установки плеер уже не нашёл бы файлов. Причём правка .pro-файла не помогала — любая попытка поставить в source и target одинаковые значения приводила к тому, что qmake сходил с ума и говорил, что я пытаюсь скопировать файл сам в себя. Такой расклад меня очевидно не устроил. Я попробовал положить все ресурсы в QRC — если они будут в одном файле, потеряться они просто не смогут. Оказалось, что и тут подвох — автоматически прикладываемый к новому проекту класс qmlapplicationviewer портил все qrc:/ ссылки. Эту ошибку я уже оказался морально готов исправить, и все ресурсы, включая html-страницу с описанием игры, переехали в qrc. Всё заработало, но теперь каждый запуск qtcreator говорит мне, что мой qmlapplicationviewer отличается от его, и не хочу ли я затереть все изменения?
Исправления для стандартного qmlapplicationviewer, чтобы он стал правильным, можно взять тут, а я пока напомню несколько простых правил для тех, кто захочет последовать моему примеру:
  • если файл лежал по адресу qml/Tzaar.qml относительно корня проекта, то в qrc его надо искать как qrc:/qml/Tzaar.qml
  • если QML-файлы все лежали рядом, то их можно подгружать прямо по относительному пути, например: «Piece.qml» — то есть, изменений вносить не придётся
  • если мы из QML хотим подгрузить файл, который лежал в другом каталоге (скажем, field.js, который у нас лежал в корне проекта, а не в папке qml), то нам надо просто написать "/field.js" — то есть, добавить слэш в начале адреса, сделать путь абсолютным

Следуя этой простой методике, вы, конечно, лишаетесь пресловутой «гибкости» и возможности заменить QML без перекомпиляции — но надо ли оно игре? Зато вы никогда не пострадаете от того, что в разных дистрибутивах и системах разделяемые данные лежат по разным адресам. Из альтернативных решений можно: переписать всю систему сборки на CMake либо полезть во внутренности .pri-файла от QmlApplicationViewer, что ещё хуже. Мне почему-то кажется, что сгенерировать файл ресурсов и поправить несколько путей — гораздо более простое решение.

Итог

Интерфейсы на QML проектируются действительно очень легко и быстро, даже такими интерфейсо-ненавистниками, как я.
На QML можно написать игру или приложение, работающее с онлайн-сервисом. Если аккуратно обогнуть спрятанные грабли, то простота и удобство вас очень приятно удивят.
При толике желания из всего кода можно сделать один приятный бинарник со всеми включенными ресурсами, который и распространять
Работа с сетью всё-таки хромает. Мне кажется, разработчики игр для мобильных устройств (особенно в свете пиара NFC и тому подобного) были бы счастливы, будь у них возможность нормально установить соединение между устройствами, не вылезая на уровень C++
Искоробочный пример в qtcreator — сломан. Это страшный минус. Когда неправильно работает приложенный к IDE хеллоуворлд, это кидает страшную тень на всю библиотеку. В данном случае это наезд в сторону разработчиков QtCreator, а не самой технологии. Тем не менее, имейте в виду. Возможно, в версиях более поздних, чем моя 2.2.1, эту проблему исправили.
Судя по отпиленному синхронному режиму в XMLHttpRequest и кривой работе с побайтовым чтением, у меня сложилось ощущение, что JS-движок в Qt местами ущербен. Будьте аккуратнее.
Желающие сыграть в эту замечательную игру могут прочитать правила (возможно, их русская версия от Игроведа будет понятнее), скомпилировать игру командой qmake && make, и сыграть — только помните, что вам нужен партнёр, который тоже подключится к серверу.
Пользователи 32-битной Ubuntu 11.10 (может, и других систем, не обещаю) могут без затей скачать архив: sorokdva.net/tzaar/tzaar.tar.gz и запустить уже собранный бинарник. Для работы нужен пакет libqtwebkit-qmlwebkitplugin.

И если кому-то вдруг показалось, что это я усиленно ругаю QML, то я напомню два момента. Первый: на QML я написал игру в свободное время за, в общей сложности, максимум 40 часов (на самом деле меньше). Второй: на традиционном Qt и с его работой с графикой я написать игру не смог. И это должно уже говорить само за себя. А я в общем-то не то чтобы неосилятор.
Победа!
Tags:
Hubs:
+31
Comments 13
Comments Comments 13

Articles