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

от автора

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

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

Вдаваться в подробности того что из себя представляют тайлы мне не хочется, да и статья немного не об этом. Предполагается, что читатель уже имеет некоторое представление о том, что такое изометрия в играх, что такое тайлы, что они из себя представляют и как рисуются. Напомню лишь о том, что элементарный изометрический тайл создается в соотношении 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)

ссылка на оригинал статьи https://habrahabr.ru/post/276457/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *