Создание 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
#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
Чтобы спрятать зубастость справа и снизу, нужно просто ограничить скроллинг путем изменения параметров 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()));
}
Релиз
Ну вот и все, теперь можно запустить проект и увидеть такую картинку:
которая не сильно-то и отличается от картинки в первом проекте, но является менее прожорливой.
Для того чтобы увидеть как рисуется карта в движении, нужно увеличить значение переменной root.mapcols, установив его, например, в значение 8 (это значение умноженное на root.maprows соответствует количеству элементов в переменной mapprovider.mapdata, для больших значений будет необходимо добавить элементы).
Для того чтобы спрятать «защитную зону» за кулисы, оставив видимой только полезную часть карты, достаточно изменить параметр gamearea.clip с false на true
Исходник проекта (vk.com)