Первая мысль, которая меня посетила: «а что, собственно, в этом сложного?».
Ну, вроде, ничего:
• создаешь массив текстур,
• указываешь размер карты,
• пробегаешься циклом по массиву, создавая объекты.
Именно так я и поступил с самого начала…
Небольшое отступление
Вдаваться в подробности того что из себя представляют тайлы мне не хочется, да и статья немного не об этом. Предполагается, что читатель уже имеет некоторое представление о том, что такое изометрия в играх, что такое тайлы, что они из себя представляют и как рисуются. Напомню лишь о том, что элементарный изометрический тайл создается в соотношении 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
ссылка на оригинал статьи https://habrahabr.ru/post/276457/
Добавить комментарий