Pull to refresh

Qt Quick и Box2d: Симулируем физику

Reading time 12 min
Views 14K
Этот пост участвует в конкурсе „Умные телефоны за умные посты
image
Даже не смотря на то, что многие программисты, в данный момент, не спешат переводить разработку своих приложений и игр на рельсы Qt Quick, инфраструктура вокруг самой технологии с каждым днём лишь растёт и развивается.

Вот и до симуляции физики в двухмерном пространстве дошло дело. А вернее до появления QML-плагина. который позволяет с присущей Qt Quick легкостью интегрировать в своё приложения физический движок Box2D. Вот об этом сегодня и поговорим. А точнее, разберём на примере реализации простого арканоида, насколько быстро можно создать простенькую игру, никогда ранее не работая с физическими движками и почти незная терминологии.

Связываем QML и Box2d


Первым делом, нам необходимо получить исходники плагина. Для этого переходим по ссылке gitorious.org/qml-box2d/qml-box2d/trees/master и справа кликаем по кнопке «Download master as tar.gz». Откладываем пока архив в сторонку и переходим в Qt Creator.

Здесь, создаём новый проект, типа «Приложение Qt Qucik». В мастере вводим название, расположение в файловой системе, выбираем профиль Qt, далее, далее, завершить.

А вот теперь начинается одна из самых важных частей. И обычно одна из самых сложных в ДРУГИХ языках и технологиях. Необходимо собственно подключить плагин к свежесозданному приложению. Для этого, распаковываем полученный архив в корневой каталог приложения и переименовываем получившийся каталог qml-box2d-qml-box2d в qml-box2d. B вносим одну новую строчку в .pro файл нашегно приложения:
include(qml-box2d/box2d-static.pri)

А main.cpp приведём к такому виду:
#include <QtGui/QApplication>
#include "qmlapplicationviewer.h"
#include "box2dplugin.h"

Q_DECL_EXPORT int main(int argc, char *argv[])
{
    QScopedPointer<QApplication> app(createApplication(argc, argv));

    Box2DPlugin plugin;
    plugin.registerTypes("Box2D");

    QScopedPointer<QmlApplicationViewer> viewer(QmlApplicationViewer::create());

    viewer->setOrientation(QmlApplicationViewer::ScreenOrientationLockLandscape);
    viewer->setMainQmlFile(QLatin1String("qml/Quickanoid/main.qml"));
    viewer->showExpanded();

    return app->exec();
}

Здесь строчкой #include «box2dplugin.h» включаем заголовок плагина, а строчками
    Box2DPlugin plugin;
    plugin.registerTypes("Box2D");

регистрируем в приложении типы Qt/Box2D, которые будут доступны и необходимы нам в будущем в QML.
Вот и всё. Этого достаточно для подключения плагина как статически линкуемой библиотеки. Конечно же, плагин можно собрать как самостоятельную единицу и сложить в общий каталог всех QML-плагинов в системе. Но для нашей цели подойдёт и первый вариант. Внешний вид получающегося проекта примерно таков:
image

Если сейчас попробовать скомпилировать приложение, то мы увидим стандартный Hello World, который является шаблоном для проекта по умолчанию в Qt Quick. Но это не интересно. Нам интересно использовать физику.

Формализуем описание игры


Итак, мы определились что будем делать арканоид. Перечислим, что нам необходимо в игрушке данного плана:
  • Окно по умолчанию 360x640 — для более легкого портирования в будущем на мобильные устройства. И конечно закрепления его в ландшафтном режиме.
  • Задний фон приложения — простенькая картинка, на фоне которой будет удобно играть.
  • 4 стены, ограничивающие наш мир по краям окна.
  • Шарик, летающий в пределах мира.
  • Платформа в нижней части окна, для отбивания шарика.
  • Несколько кирпичиков в верхней части окна, которые необходимо сбивать нашим шариком.
  • Счётчик времени на экране.
  • Стартовый и финишный экраны игры.

Реализуем составленное задание


Вот по этому простенькому ТЗ и будем работать далее. Как было показано выше, в main.cpp, мы уже указали нашему приложению запускаться в ландшафтном режиме. Значит больше необходимости править C++-код у нас нет. Открываем файл main.qml и приводим его к виду:
import QtQuick 1.0
import Box2D 1.0

Image {
    id: screen;

    source: "images/bg.jpeg"
    width: 640
    height: 360

    World {
        id: world
        width: screen.width
        height: screen.height

        gravity.x: 0
        gravity.y: 0
    }
}

Что мы сделали? Мы создали окошко размером 640x360, задали его фон и добавили один дочерний элемент типа World, который в будущем должен является предком для всех физических объектов. Как несложно догадаться, объект World описывает весь игровой мир и задаёт его базовые параметры, а именно:
  • gravity — гравитация по X и Y. Для нашего приложения гравитация не нужна.
  • И несколько параметров, с правильным переводом которых, у меня, к сожалению, возникают проблемы: timeStep, velocityIterations, positionIterations, frameTime

Их описание можно подглядеть в заголовочном файле box2dworld.h

Пустой физический мир в три строчки — это клёво. Но давайте разбавим его статикой. Или стенами, кому как угодно. Создадим новый QML-файл, назовём его Wall.qml. сложим рядом с приложением и заполним следующим содержимым:
import QtQuick 1.0
import Box2D 1.0

Body {

    property alias image: image.source

    bodyType: Body.Static
    fixtures: Box {
        anchors.fill: parent
        friction: 1.0
    }

    Rectangle {
        anchors.fill: parent
        color: "brown"
    }

    Image {
        id: image

        anchors.fill: parent
        source: "images/wall.jpg"

        fillMode: Image.Tile
    }
}

Перерыв на теорию

Стена, как и все объекты на сцене (а объект Wold — это по сути сцена), являются объектами типа Body. Следовательно, Body — базовый класс для всех физических элементов. Он имеет следующие свойства:
  • active — включить/выключить физику на элементе
  • linearVelocity — линейное ускорение
  • fixtures — границы тела, по которым будут определяться столкновения
  • bodyType — тип тела, статическое, динамическое или кинематическое
  • fixedRotation — запретить вращение
  • sleepingAllowed — разрешить автоматически отключать физику для экономии ресурсов
  • linearDamping, angularDamping, bullet — не понятно с первого взгляда

Тело, как таковое не может обрабатывать столкновения с другими объектами. Для того, чтобы научить тело этому, необходимо задать свойство fixtures. Значениями для этого свойства могут являться Circle, Box и Polygon. Все они являются потомками базового класса Fixture, отвечающего за взаимодействие с другими объектами. Самостоятельно он конечно же недоступен из QML, а только через трёх своих потомков. Перечислим для наглядности доступные свойства.
Класс Fixture:
  • density — удельный вес
  • friction — сила трения
  • restitution — упругость/отдача
  • groupIndex — индекс в группе (предположительно группа — один объект Body)
  • collidesWith — список объектов. с которыми соприкасается текущий объект в текущий момент
  • sensor, categories — дополнительные параметры

Каждый из потомков немного расширяет данный класс собственными свойствами:
  • Класс Box, не добавляет новых свойств, но использует стандартные ширину и высоту для задания границ прямоугольника.
  • Класс Circle вводит свойство radius, который как это ни странно является радиусом круглого объекта, например колеса.
  • Класс Polygon добавляет свойство verticles, содержащий список вершин объекта для более точной физической симуляции.

Обратно к практике

Из теории становится понятно, что стена представляет собой физическое тело (Body) типа прямоугольника (Box) и графически представляется картинкой с заливкой. И теперь, имея одну стену, мы можем создать сколько угодно стен, нам же нужно их 4. Открываем main.qml и внутрь объекта World, после gravity.y: 0, дописываем описание наших стен:
Wall {
    id: wallLeft

    width: 10
    anchors {
        bottom: parent.bottom
        left: parent.left
        top: parent.top
    }
}

Wall {
    id: wallRight

    width: 10
    anchors {
        bottom: parent.bottom
        right: parent.right
        top: parent.top
    }
}

Wall {
    id: wallTop

    height: 10
    anchors {
        left: parent.left
        right: parent.right
        top: parent.top
    }
}

Wall {
    id: wallBottom

    height: 10
    anchors {
        left: parent.left
        right: parent.right
        bottom: parent.bottom
    }
}

Сохраняем всё и запускаем наше приложение, на экране мы увидим фоновый рисунок и 4 стены обрамляющие мир по краям.
image
Далее по плану у нас шарик, который может летать в пределах нашего мира и ударяться о стены. Для описания шарика создаём файл Ball.qml и заполняем его содержимым следующего характера:
import QtQuick 1.0
import Box2D 1.0

Body {
    id: ball

    fixedRotation: false
    sleepingAllowed: false

    fixtures: Circle {
        id: circle
        radius: 12
        anchors.fill: parent
        density: 0;
        friction: 10;
        restitution: 1.05;
    }

    Image {
        id: circleRect

        anchors.centerIn: parent
        width: circle.radius * 2
        height: width
        smooth: true

        source: "images/ball.png"
    }
}

Тоже самое что и со стеной, только вместо Box у нас Circle. Добавим наш шарик в созданный нами мир, после описания последней стены в объект World дописываем описание шарика:
Ball {
    id: ball
    x: parent.width/2
    y: parent.height/2
}

Запускаем, видим шарик по центру экрана, который никуда не двигается за неимением гравитации и линейного ускорения. От умница какой…
Следующий шаг — платформа, представляющая единственный элемент управления игрока, которой мы будем отбивать шарик. По прежней схеме, новый файл Platform.qml, в нём:
import QtQuick 1.0
import Box2D 1.0

Body {
    id: platform
    width: platformBg.width
    height: platformBg.height
    x: parent.width/2 - width/2
    y: parent.height - platformBg.height - 5

    bodyType: Body.Static
    fixtures: Box {
        id: platformBox
        anchors.fill: parent
        friction: 10
        density: 300;
    }

    Image {
        id: platformBg
        smooth: true
        source: "images/platform.png"
    }

    MouseArea {
        anchors.fill: parent
        drag.target: platform
        drag.axis: Drag.XAxis
        drag.minimumX: 0
        drag.maximumX: screen.width - platform.width
    }
}

Этот физический объект отличается от прочих тем, что мы позволяем пользователю водить им по экрану при помощи курсора мышки/пальца в горизонтальном направлении. В main.qml после описания Ball добавляем описание платформы:
Platform {
   id: platform
}

В данный момент советую вспомнить о наших стенах. По хорошему, мы точно знаем, что они работают, но так как мы ограничены размерами экрана, мы можем скрыть наши стены за пределы экрана. чтобы не мозолили глаза и не мешались. Для этого, по очереди, в каждый из объектов Wall внутри World добавим по одному из свойств: к левой leftMargin: -width, к правой rightMargin: -width, к верхней topMargin: -height, и к нижней bottomMargin: -height. После чего вновь запустим и глянем на то что у нас получается:
image

Следующий пункт нашего плана. Кирпичики, которые необходимо сбивать шариком. Но! Мы не должны забывать, что места на экране у нас будет маловато. Поэтому попробуем реализовать данную часть игры иначе. А именно — вверху экрана будут находиться несколько кирпичиков зелёного цвета, по которым постоянно надо лупить шариком, не допуская того чтобы они стали красными. Если кирпич становится красным окончательно — лупить по нему уже бестолку. А в игру введём таймер, отсчитывающий количество времени до того момента, пока все кирпичики не покраснеют. Анимация перехода из зелёного в красный будет равна к примеру 20 секундам. После того как кирпич краснеет окончательно, он исчезает. Если же мы успеваем попасть по кирпичу, то 20-секундный таймер обнуляется и кирпичик начинает краснеть заново. Начнём с описания кирпичика в файле Brick.qml:
import QtQuick 1.0
import Box2D 1.0

Body {
    id: brick
    width: parent.width/5 - 5
    height: 15
    anchors {
        top: parent.top
        topMargin: -height/2
    }

    signal disappear()

    property bool contacted : false

    bodyType: Body.Static
    fixtures: Box {
        anchors.fill: parent
        friction: 1.0

        onBeginContact: {
            contacted = true
        }
        onEndContact: {
            contacted = false
        }
    }

    Timer {
        id: timer
        interval: 20000; running: true; repeat: false
        onTriggered: { brick.visible = false; brick.active = false; disappear(); }
    }

    Rectangle {
        id: brickRect
        anchors.fill: parent
        radius: 6
        state: "green"

        states: [
            State {
                name: "green"
                when: brick.contacted
                PropertyChanges {
                    target: brickRect
                    color: "#7FFF00"
                }
                PropertyChanges {
                    target: timer
                    running: false
                }
            },
            State {
                name: "red"
                when: !brick.contacted
                PropertyChanges {
                    target: brickRect
                    color: "red"
                }
                PropertyChanges {
                    target: timer
                    running: true
                }
            }
        ]
        transitions: [
            Transition {
                from: "green"
                to: "red"
                ColorAnimation { from: "#7FFF00"; to: "red"; duration: 20000; }
            }
        ]
    }

    function show() {
        brick.visible = true;
        brick.active = true;
        state = "green"
    }
    function hide() {
        brick.visible = false;
        brick.active = false;
    }
}

Как видим, здесь также нет ничего сложного: описание тела, описание его отображения, два состояния с плавной анимацией перехода между ними, таймер отсчитывающий 20 секунд с перезапуском после каждого столкновения с шариком и вспомогательная функция show(). В файле main.qml после объявления платформы добавляем объявления наших кирпичиков:
Brick {
    id: brick1
    x: 3;
    onDisappear: bricksCount--
}
Brick {
    id: brick2
    anchors {
        left:brick1.right
        leftMargin: 5
    }
    onDisappear: bricksCount--
}
Brick {
    id: brick3
    anchors {
        left:brick2.right
        leftMargin: 5
    }
    onDisappear: bricksCount--
}
Brick {
    id: brick4
    anchors {
        left:brick3.right
        leftMargin: 5
    }
    onDisappear: bricksCount--
}
Brick {
    id: brick5
    anchors {
        left:brick4.right
        leftMargin: 5
    }
    onDisappear: bricksCount--
}

Кстати, не спрашивайте меня, почему я не воспользовался элементами Row и Repeat — с их использованием для автоматического создания элементов типа Body приложение падает. В самое начало файла добавим объявление новой переменной, после определения высоты и ширины:
property int bricksCount: 5

По ней будем считать количество оставшихся кирпичей, когда оно поравняется к примеру двум — завершаем игру. То есть логика взаимодействия пользователя с игрой будет проста — необходимо, чтобы как можно больше времени на экране оставалось как минимум три кирпичика. Опишем счётчик секунд в самом низу объекта World:
Text {
    id: secondsPerGame
    anchors {
        bottom: parent.bottom
        left: parent.left
    }
    color: "white"
    font.pixelSize: 36
    text: "0"
    Timer {
        interval: 1000; running: true; repeat: true
        onTriggered: secondsPerGame.text = parseInt(secondsPerGame.text) + 1
    }
}

Что же нам остаётся? Остаётся добавить экраны старта и финиша, ну и чуток поправить логику игры. Собственно это мелочи, которые в статье можно опустить. Приведу лишь наконец полный итоговый листинг файла main.qml:
import QtQuick 1.0
import Box2D 1.0

Image {
    id: screen;

    source: "images/bg.jpeg"
    width: 640
    height: 360
    property int bricksCount: 5

    World {
        id: world
        width: screen.width
        height: screen.height
        visible: false

        gravity.x: 0
        gravity.y: 0

        Wall {
            id: wallLeft

            width: 10
            anchors {
                bottom: parent.bottom
                left: parent.left
                leftMargin: -width
                top: parent.top
            }
        }

        Wall {
            id: wallRight

            width: 10
            anchors {
                bottom: parent.bottom
                right: parent.right
                rightMargin: -width
                top: parent.top
            }
        }

        Wall {
            id: wallTop

            height: 10
            anchors {
                left: parent.left
                right: parent.right
                topMargin: -height
                top: parent.top
            }
        }

        Wall {
            id: wallBottom

            height: 10
            anchors {
                left: parent.left
                right: parent.right
                bottom: parent.bottom
                bottomMargin: -height
            }
            onBeginContact: {
                console.log(other)
                finishGame()
            }
        }

        Ball {
            id: ball
            x: parent.width/2
            y: parent.height/2
        }

        Platform {
            id: platform
        }

        Brick {
            id: brick1
            x: 3;
            onDisappear: bricksCount--
        }
        Brick {
            id: brick2
            anchors {
                left:brick1.right
                leftMargin: 5
            }
            onDisappear: bricksCount--
        }
        Brick {
            id: brick3
            anchors {
                left:brick2.right
                leftMargin: 5
            }
            onDisappear: bricksCount--
        }
        Brick {
            id: brick4
            anchors {
                left:brick3.right
                leftMargin: 5
            }
            onDisappear: bricksCount--
        }
        Brick {
            id: brick5
            anchors {
                left:brick4.right
                leftMargin: 5
            }
            onDisappear: bricksCount--
        }

        Text {
            id: secondsPerGame
            anchors {
                bottom: parent.bottom
                left: parent.left
            }
            color: "white"
            font.pixelSize: 36
            text: "0"
            Timer {
                id: scoreTimer
                interval: 1000; running: true; repeat: true
                onTriggered: secondsPerGame.text = parseInt(secondsPerGame.text) + 1
            }
        }
    }

    Item {
        id:screenStart
        anchors.fill: parent
        visible: false
        Text {
            id: startGame
            anchors.centerIn: parent
            color: "white"
            font.pixelSize: 36
            text: "Start Game!"
            MouseArea {
                anchors.fill: parent
                onClicked: {
                    screen.startGame()
                }
            }
        }
    }

    Item {
        id:screenFinish
        anchors.fill: parent
        visible: false
        Text {
            id: score
            anchors.centerIn: parent
            color: "white"
            font.pixelSize: 36
            text: "Game over! Your score is: " + secondsPerGame.text
        }
        Text {
            id: restartGame
            anchors {
                top: score.bottom
                horizontalCenter: parent.horizontalCenter
            }
            color: "white"
            font.pixelSize: 36
            text: "Restart Game!"
            MouseArea {
                anchors.fill: parent
                onClicked: {
                    screen.startGame()
                }
            }
        }
    }

    function startGame() {
        screen.state = "InGame";
        bricksCount = 5
        brick1.show()
        brick2.show()
        brick3.show()
        brick4.show()
        brick5.show()
        secondsPerGame.text = "0"
        platform.x = screen.width/2 - platform.width/2
        ball.linearVelocity.x = 0
        ball.linearVelocity.y = 0
        ball.active = true;
        ball.x = platform.x + platform.width/2
        ball.y = platform.y - ball.height
        ball.x = screen.width/2
        ball.y = screen.height/2
        ball.applyLinearImpulse(Qt.point(50, 300), Qt.point(ball.x, ball.y))
        scoreTimer.running = true
    }

    function finishGame(){
        screen.state = "FinishScreen";
        brick1.hide()
        brick2.hide()
        brick3.hide()
        brick4.hide()
        brick5.hide()
        ball.active = false;
        ball.applyLinearImpulse(Qt.point(0,0), Qt.point(ball.x, ball.y))
        scoreTimer.running = false
    }

    onBricksCountChanged: {
        console.log(bricksCount)
        if (bricksCount <=2){
            finishGame()
        }
    }

    Component.onCompleted: {
        startGame()
    }

    states: [
        State {
            name: "StartScreen"
            PropertyChanges {
                target: screenStart
                visible: true
            }
        },
        State {
            name: "InGame"
            PropertyChanges {
                target: world
                visible: true
            }
        },
        State {
            name: "FinishScreen"
            PropertyChanges {
                target: screenFinish
                visible: true
            }
        }
    ]
    state: "StartScreen"
}

В сумме


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

На мой взгляд вышло неплохо. Собственно говоря, на разработку самого приложения и на написание данной статьи ушло всего два вечера (вчера и сегодня). Это во первых говорит о простоте и очень низком пороге входа в разработку с использованием QML, а во вторых — о том качестве кода, которого раз за разом удаётся достигать разработчикам как самого фреймворка Qt, так и сторонних разработчиков, пишущих для него подобные плагины.

Плюс. хочется конечно же отметить, что Box2D сам по себе не имеет привязок к какой-либо ОС и является платформо независимым, следовательно, созданное приложение одинаково хорошо будет работать как на десктопных, так и на мобильных платформах. Ну даже в данном примере вы можете видеть скриншоты из под Windows и видео из под Linux.

Конечно. в данной статье рассмотрен не весь функционал Box2D, который был перенесён в QML, остались ещё как минимум Joint-ы. С другой стороны, я считаю, что этого материала вполне достаточно для понимания сути вещей. И уже имея представление о связке QML/Box2D можно запросто клепать игрушки с использованием физики. Это могут быть и лабиринты использующие акселерометер телефона и падающие кубики, забавно разлетающиеся от ударов друг о друга и машинки или мотоциклы по типу X-Moto и много чего ещё. При этом не забываем. что QML — лишь обёртка над C++ классами и работать собственно приложение будет так как будто изначально писалось на C++.

Как обычно, исходники можно забрать на страничке проекта: code.google.com/p/quickanoid/downloads/list

Благодарю за потраченное время.
Tags:
Hubs:
+20
Comments 25
Comments Comments 25

Articles