Создание 2D тайловой карты на QML

f7217443c0c345eba85b2256b4516ca2.png

Первая мысль, которая меня посетила: «а что, собственно, в этом сложного?».
Ну, вроде, ничего:
• создаешь массив текстур,
• указываешь размер карты,
• пробегаешься циклом по массиву, создавая объекты.
Именно так я и поступил с самого начала…

Небольшое отступление
Вдаваться в подробности того что из себя представляют тайлы мне не хочется, да и статья немного не об этом. Предполагается, что читатель уже имеет некоторое представление о том, что такое изометрия в играх, что такое тайлы, что они из себя представляют и как рисуются. Напомню лишь о том, что элементарный изометрический тайл создается в соотношении 2 к 1, т. е. если ширина тайла составляет 2 единицы, то его высота должна составить 1 единицу.
Хочу отметить, что в моем проекте будут использоваться псевдо-3D тайлы, у которых размеры составляют 1 к 1. Выглядят они так:

8bbdbaae42144c3b96a8734500d2e64b.png

но использоваться будет только половина от этого «кубика» (выделена красным). Пока что применения отсеченной нижней части я не придумал, но скорее всего в будущем она будет задействована для гор, углублений или банальных обрывов карт. Тогда скорее всего придется задействовать z-индекс…, но это уже другая история

п.с. в конце статьи имеется исходник проекта

Первые шаги


Так выглядел код в самом начале моего пути:

property int mapcols: 4 // кол-во тайлов по x (столбцы)
property int maprows: mapcols * 3 // кол-во тайлов по y (строки)
// число 3 выбрано не случайно: таким образом 
// визуально можно создать более-менее квадратный кусочек карты

function createMap() {
  // для того чтобы не использовать цикл в цикле - по столбцам и строкам
  // (ну не нравятся они мне!),
  // считаем сколько всего предстоит создать тайлов
  var tilecount = mapcols * maprows

  // а теперь создаем их
  for(var tileid = 0; tileid < tilecount; tileid++) {
    // узнаем к какой колонке и строке относится тайл
    var col = tileid % mapcols
    var row = Math.floor(tileid / mapcols)

    // определяем чётность строки
    // необходимо для того, чтобы правильно расположить нечетные тайлы
    // так как рисуются они не друг под другом, а по диагонали
    var iseven = !(row&1)

    // вычисляем позицию тайла
    var tx = iseven ? col * tilesizew : col * tilesizew + tilesizew/2
    var ty = iseven ? row * tilesizeh : row * tilesizeh - tilesizeh/2
    
    ty -= Math.floor(row/2) * tilesizeh

    // создаем компонент, передав ему все полученные параметры
    var component = Qt.createComponent("Tile.qml");
    var tile = component.createObject(mapitem, {
                                        "x": tx,
                                        "y": ty,
                                        "z": tileid,
                                        "col": col,
                                        "row": row,
                                        "id": tileid
                                    });
  }
}

Вот и всё. Приложив минимум усилий получилось создать вот такую симпатичную карту:
fab21ac3c34d432ea1552be80db2efae.png

Расписывать содержимое Tile.qml, я не стану, потому что в дальнейшем этот компонент нам вообще не понадобится. А всё потому, что делать так совершенно не стоит!
Поясню: рисуя карту с размерами 4×12 (mapcols * maprows) было создано 48 объектов. Но такое игровое поле очевидно является слишком маленьким. Если же нарисовать поле побольше, например, шириной в 20 тайлов, то его высота составит 60 тайлов, а это — 1200 визуальных объектов! Не сложно представить сколько памяти будет задействовано для хранения такого количества объектов. Одним словом — много.

Размышления


Долго думать нам новым методом создания карты не пришлось. Первым делом были обозначены основные параметры карты, которые должны быть достигнуты в новом методе:
1. карта должна быть подвижной (игрок может скроллить карту в любом направлении);
2. объекты, расположенные за пределами окна не должны отрисовываться;
3. метод должен быть максимально прост в реализации %)

Первую хотелку очень легко реализовать при помощи элемента Flickable. А почему бы и нет? Не нужно будет заморачиваться со скролами, ловлей событий и… в общем заморачиваться не придется вообще, что вполне удовлетворяет третьему пункту :-) элемент будет назван map_area — область_карты.

Чтобы дать Flickable возможность двигать карту, необходимо создать во флике элемент, с размерами равными полному размеру карты в пикселях. Для этого подойдет обычный Item — этот элемент не визуальный, благодаря чему его размеры не влияют на количество потребленной памяти. Он и будет носить ключевое имя map — карта.

Для отрисовки текстур необходимо использовать дополнительный элемент, который должен располагаться внутри элемента map. При этом его размер должен соответствовать размерам map_area, а чтобы этот элемент всегда находился «на виду», его необходимо перемещать в сторону противоположную скроллу карты. Т.е. если пользователь двигает карту влево, этот элемент должен перемещаться вправо и перерисовываться.
Для реализации этой идеи могла бы подойти связка Image с QQuickImageProvider, но их возможности довольно скудны, поэтому придется создать собственный компонент, прибегнув к темной стороне — C++. Будущий элемент будет наследником QQuickPaintedItem и ему будет присвоено имя MapProvider.

От простого к… простому


В моем представлении это выглядело как-то так:
6b284e34f41c40c58edf5c31379b7f69.png

В коде это выглядит так:

Window {
    id: root
    visible: true

    width: 600
    height: 600

    // размеры тайла 
    // все помнят, что он квадратный? Именно поэтому необходимо уточнить
    // "видимую" часть тайла, а именно размер по ширине и по высоте
    property double tilesize: 128
    property double tilesizew: tilesize 
    property double tilesizeh: tilesizew / 2
    
    // количество тайлов по X и по Y (столбцы и строки соотв.)
    property int mapcols: 20 
    property int maprows: mapcols * 3 

    Flickable {
        id: maparea

        width: root.width
        height: root.height
        contentWidth: map.width
        contentHeight: map.height

        Item {
            id: map

            width: mapcols * tilesizew
            height: maprows * tilesizeh

            Item /*MapProvider*/ {
                id: mapprovider
            }
        }
    }
}


Именно этот код будет скелетом для дальнейшей работы. Следующим шагом будет создание элемента MapProvider. Для этого в проекте создаем новый C++ класс:

class MapProvider : public QQuickPaintedItem {
    Q_OBJECT

public: 
    MapProvider(QQuickItem *parent = 0);

    void paint(QPainter *painter) {
        // вся магия будет происходить тут
    }
};

Сразу же регистрируем этот элемент в QML, для этого правим main.cpp. Его содержимое должно быть примерно таким:

#include 
#include 
#include "mapprovider.h"

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);

    // добавлена эта строка:
    qmlRegisterType("game.engine", 1, 0, "MapProvider");

    QQmlApplicationEngine engine;
    engine.load(QUrl(QStringLiteral("qrc:/main.qml")));

    return app.exec();
}

После сохранения изменений, этот элемент можно задействовать в QML.

Для этого в main.qml добавляем импорт модуля:

import game.engine 1.0


и заменяем строку

Item /*MapProvider*/ {


на

MapProvider {

Для того, чтобы наглядно показать как будет работать метод, я создал 2 дополнительных элемента на форме: внутри окна обозначил специальную область game_area, в которую переместил элемент map_area. Размер игровой области я намеренно сделал меньше размера формы, а чтобы отобразить границы этой области создал обычный Rectangle:


    // количество тайлов по X и по Y (столбцы и строки соотв.)
    property int mapcols: 20 
    property int maprows: mapcols * 3 

    Item {
        id: gamearea

        width: root.width / 2
        height: root.height / 2
        x: width / 2
        y: height / 2
        clip: false

        Flickable {
            id: maparea

            width: root.width
            height: root.height
            contentWidth: map.width
            contentHeight: map.height

            Item {
                id: map

                width: mapcols * tilesizew
                height: maprows * tilesizeh

                MapProvider {
                    id: mapprovider
                }
            }
        }
    }

    Rectangle {
        id: gameareaborder

        width: gamearea.width
        height: gamearea.height
        x: gamearea.x
        y: gamearea.y

        border.width: 1
        border.color: "red"
        color: "transparent"
    }
}

Мокрые расчеты — раздел, в котором много воды


Мы почти приблизились к отрисовке карты, но имеются некоторые нюансы, на которые стоит обратить внимание. И первый кандидат к рассмотрению — края карты. У нас они получаются »зубастыми». Это можно было наблюдать в прошлом проекте, но в новом от этого нужно избавиться. Чтобы спрятать с глаз долой зубастость слева и сверху, достаточно сместить карту (Item: map) влево и вверх на половину ширины и высоты тайла:

            Item {
                id: map

                width: mapcols * tilesizew
                height: maprows * tilesizeh
                x: -tilesizew / 2
                y: -tilesizeh / 2

518fcdd57f4847d2a6349b6ace1a47a4.png

Чтобы спрятать зубастость справа и снизу, нужно просто ограничить скроллинг путем изменения параметров contentWidth и contentHeight. Тут необходимо учесть тот факт, что саму карту мы уже сместили влево и вверх на полразмера, значит размер контента необходимо уменьшить на полный размер тайла:

        Flickable {
            id: maparea

            contentWidth: map.width - tilesizew
            contentHeight: map.height - tilesizeh

Реализация перемещения элемента MapProvider при скроллинге выглядит так:

                MapProvider {
                    id: mapprovider

                    width: gamearea.width + tilesizew * 2
                    height: gamearea.height + tilesizeh * 2
                    x: maparea.contentX + (tilesizew/2 - maparea.contentX%tilesizew + map.x)
                    y: maparea.contentY + (tilesizeh/2 - maparea.contentY%tilesizeh + map.y)


жутковато :) сейчас поясню что же тут происходит.

По сути, наша карта состоит из прямоугольных блоков, в которые вписаны ромбовидные тайлы. Благодаря этому отпадает необходимость в перерисовке видимой области карты при малейшем скролле, можно просто выделить »защитную зону» (не придумал подходящего названия) за пределами видимой области, которая тоже будет отрисовываться вместе со всей картой, а перерисовывать всю карту нужно будет только тогда, когда скроллинг превысит размер этой зоны. Благодаря этому, количество необходимых перерисовок карты уменьшится в сотни раз (в зависимости от размеров тайла).
В данном коде эта «защитная зона» рассчитывается путём прибавления к ширине и высоте MapProvider удвоенного размера тайла. Таким образом мы расширим отрисовываемую область вправо и вниз ровно на 2 тайла. Чтобы половину этой области распространить вверх и влево, необходимо подправить размеры контента у map_area и размеры карты map:

        Flickable {
            id: maparea

            contentWidth: map.width - tilesizew * 1.5
            contentHeight: map.height - tilesizeh / 2

            /* ... */

            Item {
                id: map

                width: mapcols * tilesizew + tilesizew
                height: maprows * tilesizeh / 2

Формула расчета X и Y элемента MapProvider обеспечивает ему скачкообразное перемещение только тогда, когда скроллинг выходит за пределы «защитной зоны». В дальнейшем к этим скачкам будет привязано событие перерисовки карты.

Ближе к телу


Итак, с расчетами на стороне QML покончено, теперь необходимо определится с набором дополнительных параметров, которые будут необходимы для правильной отрисовки »тела» элемента MapProvider:
1. Фактическое положение контента в map_area — понадобится для расчета номеров колонок и строк, с которых начинается отрисовка карты (отрисовка начинается сверху слева, значит мы найдем индекс верхнего левого тайла). Этим параметрам я дал имена cx и cy.
2. Размеры тайлов — необходимы для отрисовки картинок.
3. Размеры карты — понадобится для расчета реального индекса тайла.
4. Собственно, само описание текстур карты. У меня это обычный одномерный массив с наименованием ресурсов.

                MapProvider {
                    id: mapprovider

                    width: gamearea.width + tilesizew*2
                    height: gamearea.height + tilesizeh*2

                    x: maparea.contentX + (tilesizew/2 - maparea.contentX%tilesizew + map.x)
                    y: maparea.contentY + (tilesizeh/2 - maparea.contentY%tilesizeh + map.y)

                    cx: maparea.contentX
                    cy: maparea.contentY

                    tilesize: root.tilesize
                    tilesizew: root.tilesizew
                    tilesizeh: root.tilesizeh

                    mapcols: root.mapcols
                    maprows: root.maprows

                    mapdata: [
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004",
                        "0004","0004","0004","0004","0004","0004","0004","0004"
                    ]
                }


п.с. здесь »0004» — это имя ресурса картинки без расширения.

Разумеется, все эти параметры необходимо объявить на стороне C++, все это делается при помощи макроса Q_PROPERTY:

class MapProvider : public QQuickPaintedItem {
    Q_OBJECT

    Q_PROPERTY(double tilesize READ tilesize WRITE setTilesize NOTIFY tilesizeChanged)
    Q_PROPERTY(double tilesizew READ tilesizew WRITE setTilesizew NOTIFY tilesizewChanged)
    Q_PROPERTY(double tilesizeh READ tilesizeh WRITE setTilesizeh NOTIFY tilesizehChanged)
    Q_PROPERTY(double mapcols READ mapcols WRITE setMapcols NOTIFY mapcolsChanged)
    Q_PROPERTY(double maprows READ maprows WRITE setMaprows NOTIFY maprowsChanged)
    Q_PROPERTY(double cx READ cx WRITE setCx NOTIFY cxChanged)
    Q_PROPERTY(double cy READ cy WRITE setCy NOTIFY cyChanged)
    Q_PROPERTY(QVariantList mapdata READ mapdata WRITE setMapdata NOTIFY mapDatachanged)

public:
/* ... */
}

Мощь QtCreator'a позволит без труда и без запинки создать все эти параметры парой кликов (для тех, кто не в курсе: вызываем контекстное меню на каждой строке Q_PROPERTY → Refactor → Generate Missing Q_PROPERTY Members…)

Финал


Наконец, мы добрались до реализации метода paint. На самом деле он не сильно отличается от функции createMap () из предыдущего проекта, за исключением того, что в него добавлено кеширование картинок:

void MapProvider::paint(QPainter *painter) {
    // получаем номера колонки и строки, с которых начинается отрисовка
    int startcol = qFloor(m_cx / m_tilesizew);
    int startrow = qFloor(m_cy / m_tilesizeh);

    // рассчитываем количество видимых тайлов
    int tilecountw = qFloor(width() / m_tilesize);
    int tilecounth = qFloor(height() / m_tilesize) * 4;
    int tilecount = tilecountw * tilecounth;

    int col, row, globcol, globrow, globid = 0;
    double tx, ty = 0.0f;
    bool iseven;
    QPixmap tile;
    QString tileSourceID;

    for(int tileid = 0; tileid < tilecount; tileid++) {
        // узнаем к какой колонке и строке относится тайл
        col = tileid % tilecountw;
        row = qFloor(tileid / tilecountw) ;

        // узнаем реальные колонку, строку и индекс тайла
        globcol = col + startcol;
        globrow = row + startrow * 2;
        globid = m_mapcols * globrow + globcol;

        // если вдруг описание карты было заполнено неправильно
        // то на карте появится белая дыра
        if(globid >= m_mapdata.size()) {
            return;
        }
        // не рисуем то, что осталось за пределами видимости
        else if(globcol >= m_mapcols || globrow >= m_maprows) {
            continue;
        }

        // определяем чётность строки
        iseven = !(row&1);

        // вычисляем позицию тайла
        tx = iseven ? col * m_tilesizew : col * m_tilesizew + m_tilesizew/2;
        ty = iseven ? row * m_tilesizeh : row * m_tilesizeh - m_tilesizeh/2;

        ty -= qFloor(row/2) * m_tilesizeh;

        // вытягиваем название ресурса по его индексу
        tileSourceID = m_mapdata.at(globid).toString();

        // достаем картинку из кеша, если она там есть
        if(tileCache.contains(tileSourceID)) {
            tile = tileCache.value(tileSourceID);
        }
        // либо создаем картинку нужного размера и скидываем в массив
        else {
            tile = QPixmap(QString(":/assets/texture/%1.png").arg(tileSourceID))
                    .scaled(QSize(m_tilesize, m_tilesize),
                            Qt::IgnoreAspectRatio,
                            Qt::SmoothTransformation);

            tileCache.insert(tileSourceID, tile);
        }

        // рисуем тайл
        painter->drawPixmap(tx, ty, tile);

        // подписываем информацию о тайле
        painter->setFont(QFont("Helvetica", 8));
        painter->setPen(QColor(255, 255, 255, 100));

        painter->drawText(QRectF(tx, ty, m_tilesizew, m_tilesizeh),
                          Qt::AlignCenter,
                          QString("%1\n%2:%3").arg(globid).arg(globcol).arg(globrow));
    }
}

Кеширование необходимо для того чтобы каждый раз не перерисовывать картинку, а перерисовывается она из-за того, что размеры исходной картинки намного больше размеров тайла (это сделано для реализации масштабирования в будущем). Перерисовка съедает много ресурсов, особенно из-за того что при изменении картинки используется сглаживание Qt: SmoothTransformation.
К слову, теоретически масштабирование можно реализовать и сейчас, достаточно лишь добавить фактор увеличения для параметра root.tilesize

Переменная tileCache объявляется в классе MapProvider:

private:
    QMap tileCache;

И последний штрих — это добавление события перерисовки карты путем создания пары коннектов:

MapProvider::MapProvider(QQuickItem *parent) :
    QQuickPaintedItem(parent) {
    connect(this, SIGNAL(xChanged()), this, SLOT(update()));
    connect(this, SIGNAL(yChanged()), this, SLOT(update()));
}

Релиз


Ну вот и все, теперь можно запустить проект и увидеть такую картинку:
577285768a544f8d847739f710d12a80.png
которая не сильно-то и отличается от картинки в первом проекте, но является менее прожорливой.

Для того чтобы увидеть как рисуется карта в движении, нужно увеличить значение переменной root.mapcols, установив его, например, в значение 8 (это значение умноженное на root.maprows соответствует количеству элементов в переменной mapprovider.mapdata, для больших значений будет необходимо добавить элементы).

Для того чтобы спрятать «защитную зону» за кулисы, оставив видимой только полезную часть карты, достаточно изменить параметр gamearea.clip с false на true

Исходник проекта (vk.com)

© Habrahabr.ru