Создание 2D тайловой карты на QML. Часть 1

  • Tutorial


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

Небольшое отступление

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



но использоваться будет только половина от этого «кубика» (выделена красным). Пока что применения отсеченной нижней части я не придумал, но скорее всего в будущем она будет задействована для гор, углублений или банальных обрывов карт. Тогда скорее всего придется задействовать 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
                                    });
  }
}


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


Расписывать содержимое 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.

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


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


В коде это выглядит так:
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 <QGuiApplication>
#include <QQmlApplicationEngine>
#include "mapprovider.h"

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

    // добавлена эта строка:
    qmlRegisterType<MapProvider>("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




Чтобы спрятать зубастость справа и снизу, нужно просто ограничить скроллинг путем изменения параметров 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<QString, QPixmap> tileCache;


И последний штрих — это добавление события перерисовки карты путем создания пары коннектов:
MapProvider::MapProvider(QQuickItem *parent) :
    QQuickPaintedItem(parent) {
    connect(this, SIGNAL(xChanged()), this, SLOT(update()));
    connect(this, SIGNAL(yChanged()), this, SLOT(update()));
}


Релиз


Ну вот и все, теперь можно запустить проект и увидеть такую картинку:

которая не сильно-то и отличается от картинки в первом проекте, но является менее прожорливой.

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

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

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

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

  • +14
  • 15,5k
  • 8
Поделиться публикацией

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 8

    +2
    На этих выходных на globalgamejam делал игру с изометрической проекцией, и также сначала не знал, как это делать правильно. Сначала выпросил у художника спрайты нужных мне объектов, а потом рисовал их как есть. В итоге уперся на ограничение движка по количеству спрайтов и не смог из-за этого вовремя добить уровней в игру. Но зато понял, как делать правильно:

    Сетки должно быть две. Одна — игровая (для простоты можно взять ромб с высотой 128 и шириной 256 пикселей).
    Вторая — сетка отрисовки. Эта сетка должна быть прямоугольной с высотой и шириной вдвое меньшей, чем у игровой сетки. Соответственно, каждая игровая клетка будет рисоваться на четырех соседних клетках карты тайлов. Каждый графический тайл — прямоугольник, разделенный по диагонали между двумя разными игровыми объектами.

    Подготавливаете изображение с такими тайлами, набираете из них карту, потом карта сохраняется в большую текстуру (или скорее в несколько текстур, если вам как и мне нужно, чтобы были элементы карты, за которые могут попадать другие объекты). В такую же отдельную текстуру сохраняете соответствующие коллизии, и все, отрисовывается только одна-две больших картинки, вместо сотен маленьких.

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

    Вот пример собранной таким образом карты из редактора движка defold (не обращайте внимания на стрелки, скриншот использовался в переписке, а новый быстро снять не смогу):
    скриншот


      0
      Хм. В моем примере по сути тоже рисуется всего одна картинка, только набирается она из кусочков «на лету». От одной большой текстуры я отказался, так как такой подход был тормознутым на мобильнике. Может в этом виноват QML, может руки мои, не знаю. Скорее оба, потому что тогда я рисовал картинку целиком, а ее можно было бы тоже «пилить» по краям игровой формы.
      Насчет сложности позиционирования ромбов вы, конечно, правы :) но вся эта сложность упирается лишь в формулы, и когда с ними разберешься, все сложности сами собой отпадают.
      За пример спасибо, есть над чем подумать )
        +1
        Ну нет, позиционирование ромбов упирается не только в формулы. Там все очень плохо на границах ромбов при стыковке. Границы дублируются, исчезают вовсе, etc. Если хотите получить pixel-perfect графику — отхватите очень много проблем с этим. А с прямоугольниками можно увидеть все огрехи на этапе набора палитры тайлов, и подкорректировать все сразу в графическом редакторе. Убирается цикл нарисовать-подложить-запустить. Все сводится к нарисовать-совместить.

        Я уже с этим столкнулся и честно, проще, когда за стычками следит тот, кто рисовал, а не тот, кто кредит.
          0
          s/стычками/стыками/;s/кредит/кодит/; (автокозамена слажала).
      +1
      Я не знаток Qt и QML, но использовать ScrollBox(Flickable) и прочие средства Qt/QML для карты в игре это в любом случае оверхед. Сам QML — это уже мега оверхед для игры.
      Достаточно иметь на форме Painbox или его аналог в Qt, и отрисовывать на нем.
      Все сказанное — ИМХО.
        0
        Так и хочется взять и… возразить :)
        В чем-то я с вами согласен, но хочу обратить ваше внимание на существование игрового движка, именуемого V-Play. Так вот, свою жизнь он увидел относительно недавно (декабрь 2012), но дела у его разработчиков идут явно в гору, ибо раньше он был платным, а сейчас появились фришные сборки. Значит могут себе это позволить. Но суть не в этом.

        Суть в том, что вся игровая база там написана исключительно на QML, и лишь «физическая часть» переложена на Box2D. QML — это грубо говоря графический движок, начиненный плюшками в виде готовых решений, таких как Flickable и т.д. Для маленьких игрулек вполне пойдет.
        0
        что помешало назвать MapProvider как Map?
          0
          Не знаю почему так сделал, наверное меня смутило то, что id "map" занят другим элементом. Либо такое название пришло от QQuickImageProvider, с которым я изначально пытался что-то сделать, но ничего не вышло, потому что передавать параметры в адресе к источнику (source) не совсем удобно.
          Думаю причина останется в тайне даже от меня самого ))

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое