📜 ⬆️ ⬇️

Create 2D tile maps in QML. Part 1



The first thought that came to me was: “and what, in fact, is this complicated?”.
Well, sort of, nothing:
• create an array of textures,
• indicate the size of the card,
• you loop around the array, creating objects.
That's exactly what I did from the very beginning ...

Small retreat

I don’t want to go into the details of what tiles are, and the article is a little different about that. It is assumed that the reader already has some idea of ​​what isometry is in games, what tiles are, what they are and how they are drawn. I recall only that an elementary isometric tile is created in a ratio of 2 to 1, that is, if the width of the tile is 2 units, then its height should be 1 unit.
I want to note that in my project pseudo-3D tiles will be used, whose sizes are 1 to 1. They look like this:


')
but only half of this “cube” will be used (highlighted in red). So far, I have not invented the use of the cut-off bottom, but most likely in the future it will be used for mountains, depressions or banal map breaks. Then you will most likely have to use the z-index ... but that's another story

ps At the end of the article there is a project source

The first steps


This is how the code looked at the very beginning of my journey:
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 }); } } 


That's all. With a minimum of effort, it turned out to create such a nice card:


I will not begin to paint the contents of Tile.qml , because in the future we will not need this component at all. And all because doing so is not worth it!
Let me explain: by drawing a map in 4x12 sizes ( mapcols * maprows ) 48 objects were created. But such a playing field is obviously too small. If we draw a larger field, for example, a width of 20 tiles, then its height will be 60 tiles, and this is 1200 visual objects ! It is not difficult to imagine how much memory will be used to store such a number of objects. In a word - a lot.

Reflections


We did not have to think long for a new method of creating a map. First of all, the main parameters of the map were identified, which should be achieved in the new method:
1. the card must be movable (the player can scroll the card in any direction);
2. Objects located outside the window should not be drawn;
3. the method should be as simple as possible to implement%)

The first Wishlist is very easy to implement using the Flickable element. Why not? It will not be necessary to bother with scrolling, catching events and ... in general, it is not necessary to bother at all, which fully satisfies the third point :-) the element will be named map_area - map_ area.

To enable Flickable to move the map, you need to create an element in the flick, with dimensions equal to the full size of the map in pixels. An ordinary Item is suitable for this - this item is not visual, due to which its dimensions do not affect the amount of memory consumed. He will have the key name map - map.

To draw textures, you need to use an additional element that must be located inside the map element. At the same time, its size must correspond to the size of map_area , and in order for this element to always be “in sight”, it must be moved to the side opposite to the map scrolling. Those. if the user moves the map to the left, this element should be moved to the right and redrawn.
To implement this idea, the Image bundle with QQuickImageProvider could be suitable, but their capabilities are rather scarce, so you have to create your own component by resorting to the dark side - C ++ . The future item will be the QQuickPaintedItem heir and will be named MapProvider .

From simple to ... simple


In my view, it looked something like this:


In code, it looks like this:
 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 } } } } 

This code will be the skeleton for further work. The next step is to create a MapProvider element. To do this, create a new C ++ class in the project:
 class MapProvider : public QQuickPaintedItem { Q_OBJECT public: MapProvider(QQuickItem *parent = 0); void paint(QPainter *painter) { //      } }; 


Immediately register this element in QML , for this we rule main.cpp . Its contents should be something like this:

 #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(); } 


After saving the changes, this element can be used in QML .

To do this, add the import module to main.qml :
 import game.engine 1.0 

and replace the string
 Item /*MapProvider*/ { 

on
 MapProvider { 


In order to visually show how the method will work, I created 2 additional elements on the form: inside the window I marked the special area game_area , into which I moved the map_area element. I deliberately made the size of the game area smaller than the size of the form, and to display the borders of this area I created the usual 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" } } 


Wet calculations - a section in which a lot of water


We are almost close to drawing the map, but there are some nuances that are worth paying attention to. And the first candidate for consideration is the edges of the map. We get them " toothy ." This could be observed in the past project, but you need to get rid of this new one. To hide the teeth on the left and the top out of sight, just move the map ( Item: map ) to the left and up by half the width and height of the tile:
  Item { id: map width: mapcols * tilesizew height: maprows * tilesizeh x: -tilesizew / 2 y: -tilesizeh / 2 




To hide the teeth on the right and below, you just need to limit the scrolling by changing the parameters contentWidth and contentHeight . Here it is necessary to take into account the fact that we have already shifted the card itself to the left and upwards by half the size, which means that the size of the content must be reduced by the full size of the tile:
  Flickable { id: maparea contentWidth: map.width - tilesizew contentHeight: map.height - tilesizeh 


The implementation of moving the MapProvider element while scrolling looks like this:
  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) 

scary :) now I'll explain what's going on here.

In fact, our map consists of rectangular blocks in which diamond-shaped tiles are inscribed. This eliminates the need to redraw the visible area of ​​the map at the slightest scrolling, you can simply select the " protection zone " (did not come up with a suitable name) outside the visible area, which will also be drawn along with the entire map, and you will only need to redraw the entire map when scrolling will exceed the size of this zone. Due to this, the number of necessary redrawing of the map will decrease hundreds of times (depending on the size of the tile).
In this code, this “protection zone” is calculated by adding twice the tile size to the width and height of the MapProvider . Thus, we will expand the rendered area to the right and down exactly 2 tiles. In order to spread half of this area up and to the left, it is necessary to tweak the content sizes of map_area and map map sizes:
  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 


The formula for calculating the X and Y of the MapProvider element provides it with hopping only when scrolling is outside the “protection zone”. In the future, a map redrawing event will be tied to these jumps.

Closer to the body


So, with the calculations on the side of QML done, now it is necessary to determine the set of additional parameters that will be necessary for the correct drawing of the “ body ” of the MapProvider element:
1. The actual position of the content in map_area - will be needed to calculate the numbers of columns and lines from which the map starts to draw (the drawing starts from the top left, then we find the index of the upper left tile). I gave these parameters the names cx and cy .
2. Tile sizes - necessary for drawing pictures.
3. Map dimensions - will be needed to calculate the real tile index.
4. Actually, the very description of the textures of the map. I have the usual one-dimensional array with the name of resources.
  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" ] } 

ps here “0004” is the name of the image resource without an extension.

Of course, all these parameters must be declared on the C ++ side, all this is done using the Q_PROPERTY macro:
 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: /* ... */ } 


The power of QtCreator 'a will allow you to easily create all these parameters with a couple of clicks (for those who do not know: call the context menu on each line Q_PROPERTY -> Refactor -> Generate Missing Q_PROPERTY Members ... )

The final


Finally, we got to the implementation of the paint method. In fact, it is not much different from the createMap () function from the previous project, except that the caching of images has been added to it:
 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)); } } 


Caching is necessary in order not to redraw the image each time, but it is redrawn due to the fact that the size of the original image is much larger than the size of the tile (this is done to implement scaling in the future). Redrawing eats up a lot of resources, especially because Qt :: SmoothTransformation is used when changing the image.
By the way, theoretically, scaling can be implemented even now; all you have to do is add an increase factor for the root parameter .

The variable tileCache is declared in the MapProvider class:
 private: QMap<QString, QPixmap> tileCache; 


And the final touch is adding a map redraw event by creating a pair of connections:
 MapProvider::MapProvider(QQuickItem *parent) : QQuickPaintedItem(parent) { connect(this, SIGNAL(xChanged()), this, SLOT(update())); connect(this, SIGNAL(yChanged()), this, SLOT(update())); } 


Release


Well, that's all, now you can run the project and see this picture:

which is not much different from the picture in the first draft, but is less voracious.

In order to see how the map is drawn in motion, you need to increase the value of the root.mapcols variable, setting it, for example, to 8 (this value multiplied by root.maprows corresponds to the number of elements in the mapprovider.mapdata variable; for larger values, you will need to add elements ).

In order to hide the “protection zone” behind the scenes, leaving only the useful part of the map visible, it is enough to change the gamearea.clip parameter from false to true

The next part will describe the process of creating a map editor based on the current project. The editor will need to be able to save the map and load it.

Source code of the current project (vk.com)

Source: https://habr.com/ru/post/276457/


All Articles