Search icon CANCEL
Subscription
0
Cart icon
Your Cart (0 item)
Close icon
You have no products in your basket yet
Save more on your purchases! discount-offer-chevron-icon
Savings automatically calculated. No voucher code required.
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Newsletter Hub
Free Learning
Arrow right icon
timer SALE ENDS IN
0 Days
:
00 Hours
:
00 Minutes
:
00 Seconds
Arrow up icon
GO TO TOP
SFML Blueprints

You're reading from   SFML Blueprints Sharpen your game development skills and improve your C++ and SFML knowledge with five exciting projects

Arrow left icon
Product type Paperback
Published in May 2015
Publisher Packt
ISBN-13 9781784398477
Length 298 pages
Edition 1st Edition
Languages
Tools
Arrow right icon
Toc

Building a generic Tile Map

For our project, we need something that will manage the map. In fact, the map is nothing but a big grid. The cells can be of any shape (square, hexagonal, and so on). The only restriction is that all the cells of a single map should have the same geometry.

Moreover, each cell can contain several objects, possibly of different types. For example, a cell can contain some background texture for the ground, a tree, and a bird. Because SFML doesn't use a z buffer with sprites (also called a depth buffer), we need to simulate it by hand. This is called the Painter's Algorithm. Its principle is very simple; draw everything but by depth order, starting with the most distant. It's how a tradition art painter would paint.

All this information brings us to the following structure:

  • A Map class must be of a specific geometry and must contain any number of layers sorted by their z buffer.
  • A Layer contains only a specific type. It also has a z buffer and stores a list of content sorted by their positions.
  • The CONTENT and GEOMETRY classes are template parameters but they need to have a specific API.

Here is the flowchart representing the class hierarchy of the previously explained structure:

Building a generic Tile Map

Following is the explanation of the flowchart:

  • The CONTENT template class can be any class that inherits from sf::Drawable and sf::Transformable.
  • The GEOMETRY class is a new one that we will learn about shortly. It only defines the geometric shape and some helper functions to manipulate coordinates.
  • The VLayer class defines a common class for all the different types of layers.
  • The Layer class is just a container of a specific type with a depth variable that defines its draw order for the painter algorithm.
  • The VMap class defines a common API for the entire Map. It also contains a list of VLayer that is displayed using the painter algorithm.
  • The Map class inherits from VMap and is of a specific geometry.

The Geometry class as an isometric hexagon

For our project, I made the choice of an isometric view with the tile as a hexagon. An isometric view is really simple to obtain but needs to be understood well. Following are the steps we need to follow:

  1. First, view your tile from the top view:
    The Geometry class as an isometric hexagon
  2. Then, rotate it 45 degrees clockwise:
    The Geometry class as an isometric hexagon
  3. Finally, divide its height by 2:
    The Geometry class as an isometric hexagon
  4. You now have a nice isometric view. Now, let's take a look at the hexagon:
    The Geometry class as an isometric hexagon

As you know, we need to calculate the coordinates of each of the edges using trigonometry, especially the Pythagoras theorem. This is without taking into account the rotation and the height resize. We need to follow two steps to find the right coordinates:

  1. Calculate the coordinates from the rotated shape (adding 45 degrees).
  2. Divide the total height value by two. By doing this, you will finally be able to build sf::Shape:
    shape.setPointCount(6);
    shape.setPoint(0,sf::Vector2f(0,(sin_15+sin_75)/2));
    shape.setPoint(1,sf::Vector2f(sin_15,sin_15/2));
    shape.setPoint(2,sf::Vector2f(sin_15+sin_75,0));
    shape.setPoint(3,sf::Vector2f(sin_15+sin_75+sin_45,sin_45/2));
    shape.setPoint(4,sf::Vector2f(sin_75+sin_45,(sin_75+sin_45)/2));
    shape.setPoint(5,sf::Vector2f(sin_45,(sin_15+sin_75+sin_45)/2));
    shape.setOrigin(height/2,height/4);
  3. The major part of the GEOMETRY class has been made. What remains is only a conversion from world to pixel coordinates, and the reverse. If you are interested in doing this, take a look at the class implementation in the SFML-utils/src/SFML-utils/map/HexaIso.cpp file.

Now that the main geometry has been defined, let's construct a Tile<GEOMETRY> class on it. This class will simply encapsulate sf::Shape , which is initialized by the geometry, and with the different requirements to be able to be use a COMPONENT parameter for the map. As this class is not very important, I will not explain it through this book, but you can take a look at its implementation in the SFML-utils/include/SFML-utils/map/Tile.tpl file.

VLayer and Layer classes

The aim of a layer is to manage any number of components at the same depth. To do this, each layer contains its depth and a container of components. It also has the ability to resort the container to respect the painter algorithm. The VLayer class is an interface that only defines the API of the layer, allowing the map to store any kind of layer, thanks to polymorphism.

Here is the header of the Layer class:

template<typename CONTENT>
class Layer : public VLayer
{
  public:
  Layer(const Layer&) = delete;
  Layer& operator=(const Layer&) = delete;
  Layer(const std::string& type,int z=0,bool isStatic=false);
  virtual ~Layer(){};

  CONTENT* add(const CONTENT& content,bool resort=true);
  std::list<CONTENT*> getByCoords(const sf::Vector2i& coords,const VMap& map);
  bool remove(const CONTENT* content_ptr,bool resort=true);
  virtual void sort() override;

  private:
  virtual void draw(sf::RenderTarget& target, sf::RenderStates states,const sf::FloatRect& viewport) override;
  std::list<CONTENT> _content;
};

As mentioned previously, this class will not only store a container of its template class argument, but also its depth (z) and an is static Boolean member contained in the Vlayer class to optimize the display. The idea under this argument is that if the content within the layer doesn't move at all, it doesn't need to repaint the scene each time. The result is stored in an internal sf::RenderTexture parameter and will be refreshed only when the scene moves. For example, the ground never moves nor is it animated. So we can display it on a big texture and display this texture on the screen. This texture will be refreshed when the view is moved/resized.

To take this idea further, we only need to display content that appears on the screen. We don't need do draw something out of the screen. That's why we have the viewport attribute of the draw() method.

All other functions manage the content of the layer. Now, take a look at its implementation:

template<typename CONTENT>
Layer<CONTENT>::Layer(const std::string& type,int z,bool isStatic) 
  : Vlayer(type,z,isStatic) {}

template<typename CONTENT>
CONTENT* Layer<CONTENT>::add(const CONTENT& content,bool resort)
{
  _content.emplace_back(content);
  CONTENT* res = &_content.back();
  if(resort)
      sort();
  return res;
}

This function adds new content to the layer, sort it if requested, and finally, return a reference to the new object:

template<typename CONTENT>
std::list<CONTENT*> Layer<CONTENT>::getByCoords(const sf::Vector2i& coords,const VMap& map)
{
  std::list<CONTENT*> res;
  const auto end = _content.end();
  for(auto it = _content.begin();it != end;++it)
  {
    auto pos = it->getPosition();
    sf::Vector2i c = map.mapPixelToCoords(pos.x,pos.y);
    if(c == coords)
        res.emplace_back(&(*it));
  }
  return res;
}

This function returns all the different objects to the same place. This is useful to pick up objects, for example, to pick objects under the cursor:

template<typename CONTENT>
bool Layer<CONTENT>::remove(const CONTENT* content_ptr,bool resort)
{
  auto it = std::find_if(_content.begin(),_content.end(),[content_ptr](const CONTENT& content)->bool
  {
    return &content == content_ptr;
  });
  if(it != _content.end()) {
    _content.erase(it);
    if(resort)
    sort();
    return true;
  }
  return false;
}

This is the reverse function of add(). Using its address, it removes a component from the container:

template<typename CONTENT>
void Layer<CONTENT>::sort()
{
  _content.sort([](const CONTENT& a,const CONTENT& b)->bool{
    auto pos_a = a.getPosition();
    auto pos_b = b.getPosition();
    return (pos_a.y < pos_b.y) or (pos_a.y == pos_b.y and pos_a.x < pos_b.x);
    });
  }
}

This function sorts all the content with respect to the painter algorithm order:

template<typename CONTENT>
void Layer<CONTENT>::draw(sf::RenderTarget& target, sf::RenderStates states,const sf::FloatRect& viewport)
{
  if(_isStatic)
  {//a static layer
    if(_lastViewport != viewport)
    { //the view has change
      sf::Vector2u size(viewport.width+0.5,viewport.height+0.5);
      if(_renderTexture.getSize() != size)
      {//the zoom has change
        _renderTexture.create(size.x,size.y);
        _sprite.setTexture(_renderTexture.getTexture(),true);
      }
      _renderTexture.setView(sf::View(viewport));
      _renderTexture.clear();

      auto end = _content.end();
      for(auto it = _content.begin();it != end;++it)
      {//loop on content
      CONTENT& content = *it;
      auto pos = content.getPosition();
      if(viewport.contains(pos.x,pos.y))
      {//content is visible on screen, so draw it
        _renderTexture.draw(content);
      }
    }
    _renderTexture.display();
    _lastViewport = viewport;
    _sprite.setPosition(viewport.left,viewport.top);
  }
  target.draw(_sprite,states);
}
else
{ //dynamic layer
  auto end = _content.end();
  for(auto it = _content.begin();it != end;++it)
  {//loop on content
    const CONTENT& content = *it;
    auto pos = content.getPosition();
    if(viewport.contains(pos.x,pos.y))
    {//content is visible on screen, so draw it
      target.draw(content,states);
    }
  }
}

This function is much more complicated than what we expect because of some optimizations. Let's explain it step by step:

  • First, we separate two cases. In the case of a static map we do as follows:
    • Check if the view port has changed
    • Resize the internal texture if needed
    • Reset the textures
  • Draw each object with a position inside the view port into the textureDisplay the texture for the RenderTarget argument.
  • Draw each object with a position inside the view port into the RenderTarget argument if the layer contains dynamic objects (not static).

As you can see, the draw() function uses a naive algorithm in the case of dynamic content and optimizes the statics. To give you an idea of the benefits, with a layer of 10000 objects, the FPS was approximately 20. With position optimization, it reaches 400, and with static optimization, 2,000. So, I think the complexity of this function is justified by the enormous performance benefits.

Now that the layer class has been exposed to you, let's continue with the map class.

VMap and Map classes

A map is a container of VLayer. It will implement the usual add()/remove() functions. This class can also be constructed from a file (described in the Dynamic board loading section) and handle unit conversion (coordinate to pixel and vice versa).

Internally, a VMap class store has the following layers:

std::vector<VLayer*> _layers;

There are only two interesting functions in this class. The others are simply shortcuts, so I will not explain the entire class. Let us see the concerned functions:

void VMap::sortLayers()
{
  std::sort(_layers.begin(),_layers.end(),[](const VLayer* a, const VLayer* b)->bool{
    return a->z() < b->z();
  });
  const size_t size = _layers.size();
  for(size_t i=0;i<size;++i)
    _layers[i]->sort();
}

This function sorts the different layers by their z buffer with respect to the Painter's Algorithm. In fact, this function is simple but very important. We need to call it each time a layer is added to the map.

void VMap::draw(sf::RenderTarget& target, sf::RenderStates states,const sf::FloatRect& viewport) const
{
  sf::FloatRect delta_viewport(viewport.left - _tile_size,
  viewport.top - _tile_size,
  viewport.width + _tile_size*2,
  viewport.height + _tile_size*2);
  const size_t size = _layers.size();
  for(size_t i=0;i<size;++i)
    _layers[i]->draw(target,states,delta_viewport);
}

The function draws each layer by calling its draw method; but first, we adjust the screen view port by adding a little delta on each of its borders. This is done to display all the tiles that appear on the screen, even partially (when its position is out on the screen).

Dynamic board loading

Now that the map structure is done, we need a way to load it. For this, I've chosen the JSON format. There are two reasons for this choice:

  • It can be read by humans
  • The format is not verbose, so the final file is quite small even for big map

We will need some information to construct a map. This includes the following:

  • The map's geometry
  • The size of each tile (cell)
  • Define the layers as per the following:
    • The z buffer
    • If it is static or dynamic
    • The content type

Depending on the content type of the layer, some other information to build this content could be specified. Most often, this extra information could be as follows:

  • Texture
  • Coordinates
  • Size

So, the JSON file will look as follows:

{
  "geometry" : {
    "name" :"HexaIso", "size" : 50.0
  },
  "layers" : [{
    "content" : "tile", "z" : 1, "static" : true,
    "data" : [{"img" :"media/img/ground.png", "x" : 0, "y" : 0, "width" : 100, "height" : 100}]
  },{
    "content" : "sprite", "z" : 3,
    "data" : [
    {"x" : 44, "y" : 49, "img" : "media/img/tree/bush4.png"},
    {"x" : 7, "y" : 91, "img" : "media/img/tree/tree3.png"},
    {"x" : 65, "y" : 58, "img" : "media/img/tree/tree1.png"}
    ]
  }]
}

As you can see, the different datasets are present to create a map with the isometric hexagon geometry with two layers. The first layer contains the grid with the ground texture and the second one contains some sprite for decoration.

To use this file, we need a JSON parser. You can use any existing one, build yours, or take the one built with this project. Next, we need a way to create an entire map from a file or update its content from a file. In the second case, the geometry will be ignored because we can't change the value of a template at runtime.

So, we will add a static method to the VMap class to create a new Map, and add another method to update its content. The signature will be as follows:

static VMap* createMapFromFile(const std::string& filename);
virtual void loadFromJson(const utils::json::Object& root) = 0;

The loadFromJson() function has to be virtual and implemented in the Map class because of the GEOMETRY parameter required by the Tile class. The createMapFromFile() function will be used internationally. Let's see its implementation:

VMap* VMap::createMapFromFile(const std::string& filename)
{
  VMap* res = nullptr;
  utils::json::Value* value = utils::json::Driver::parse_file(filename);
  if(value)
  {
    utils::json::Object& root = *value;
    utils::json::Object& geometry = root["geometry"];
    std::string geometry_name = geometry["name"].as_string();
    float size = geometry["size"].as_float();
    if(geometry_name == "HexaIso")
    {
      res = new Map<geometry::HexaIso>(size);
      res->loadFromJson(root);
    }
    delete value;
  }
  return res;
}

The goal of this function is pretty simple; construct the appropriate map depending on the geometry parameter and forward it the rest of the job.

void Map<GEOMETRY>::loadFromJson(const utils::json::Object& root)
{
    const utils::json::Array& layers = root["layers"];
    for(const utils::json::Value& value : layers) //loop through the 
rs
    {
        const utils::json::Object& layer = value;
        std::string content = layer["content"].as_string(); //get the content type
                                                                                                      
        int z = 0; //default value
        try{
            z = layer["z"].as_int(); //load value
        } catch(...){}
                                                                                                      
        bool isStatic = false; //default value
        try {
            isStatic = layer["static"].as_bool(); //load value
        }catch(...){}
        
        if(content == "tile") //is a layer or tile?
        {
            auto current_layer = new Layer<Tile<GEOMETRY>>(content,z,isStatic); //create the layer
            const utils::json::Array& textures = layer["data"];
            for(const utils::json::Object& texture : textures) //loop through the textures
            {
                int tex_x = texture["x"]; //get the tile position
                int tex_y = texture["y"];
                int height = std::max<int>(0,texture["height"].as_int()); //get the square size
                int width = std::max<int>(0,texture["width"].as_int());
                std::string img = texture["img"]; //get texture path
                                                                                                      
                sf::Texture& tex = _textures.getOrLoad(img,img); //load the texture
                tex.setRepeated(true);
                                                                                                      
                for(int y=tex_y;y< tex_y + height;++y)//create the tiles
                {
                    for(int x=tex_x;x<tex_x + width;++x)
                    {
                        Tile<GEOMETRY> tile(x,y,_tileSize);
                        tile.setTexture(&tex);
                        tile.setTextureRect(GEOMETRY::getTextureRect(x,y,_tileSize));
                                                                                                      
                        current_layer->add(std::move(tile),false);//add the new tile to the layer
                    }
                }
            }
            add(current_layer,false);//if it's a layer of images
        }
        else if(content == "sprite")
        {
            auto current_layer = new Layer<sf::Sprite>(content,z,isStatic);//create the layer
            const utils::json::Array& data = layer["data"].as_array();//loop on data
                                                                                                      
            for(const utils::json::Value& value : data)
            {
                const utils::json::Object& obj = value;
                int x = obj["x"];//get the position
                int y = obj["y"];
                float ox = 0.5;//default center value (bottom center)
                float oy = 1;
                                                                                                      
                try{//get value
                    ox = obj["ox"].as_float();
                }catch(...){}
                                                                                                      
                try{
                    oy = obj["oy"].as_float();
                }catch(...){}
                                                                                                      
                std::string img = obj["img"];//get texture path
                                                                                                      
                sf::Sprite spr(_textures.getOrLoad(img,img));//load texture
                spr.setPosition(GEOMETRY::mapCoordsToPixel(x,y,_tileSize));
                                                                                                      
                sf::FloatRect rec = spr.getLocalBounds();
                spr.setOrigin(rec.width*ox,rec.height*oy);
                                                                                                      
                current_layer->add(std::move(spr),false);//add the sprite
                                                                                                      
            }
            add(current_layer,false); //add the new layer to the map
        }
    }
    sortLayers(); //finally sort the layers (recuively)
}

For a better understanding, the previous function was explained with raw comments. It's aimed at building layers and filling them with the data picked from the JSON file.

Now that we are able to build a map and fill it from a file, the last thing we need to do is display it on the screen. This will be done with the MapViewer class.

The MapViewer class

This class encapsulates a Map class and manages some events such as mouse movement, moving the view, zoom, and so on. This is a really simple class with nothing new. This is why I will not go into details about anything but the draw() method (because of the view port). If you are interested in the full implementation, take a look at the SFML-utils/src/SFML-utils/map/MapViewer.cpp file.

So here is the draw method:

void MapViewer::draw(sf::RenderTarget& target, sf::RenderStates states) const
{
  sf::View view = target.getView();
  target.setView(_view);
  _map.draw(target,states,sf::FloatRect(target.mapPixelToCoords(sf::Vector2i(0,0),_view),_view.getSize());
  target.setView(view);
}

As usual, we receive sf::RenderTarget and sf::RenderStates as parameters. However, here we don't want to interact with the current view of the target, so we make a backup of it and attach our local view to the rendered target. Then, we call the draw method of the internal map, forwarding the target, and states but adding the view port. This parameter is very important because it's used by our layers for optimization. So, we need to build a view port with the size of the rendered target, and thanks to SFML, it's very simple. We convert the top-left coordinate to the world coordinate, relative to our view. The result is in the top-left coordinate of the displayed area. Now, we only need the size. Here again, SFML provides use all the need: sf::View::getSize(). With this information, we are now able to build the correct view port and pass it to the map draw() function.

Once the rendering is complete, we restore the initial view back to the rendered target.

A usage example

We now have all the requirements to load and display a map to the screen. The following code snippet shows you the minimal steps:

int main(int argc,char* argv[])
{
  sf::RenderWindow window(sf::VideoMode(1600,900),"Example Tile");
  sfutils::VMap* map = sfutils::VMap::createMapFromFile("./map.json");
  sfutils::MapViewer viewer(window,*map);
  sf::Clock clock;
  while (window.isOpen())
  {
    sf::Event event;
    while (window.pollEvent(event))
    {
      if (event.type == sf::Event::Closed)   // Close window : exit
      window.close();
    }
    window.clear();
    viewer.processEvents();
    viewer.update(clock.restart().asSeconds());
    viewer.draw();
    window.display();
  }
  return 0;
}

The different steps of this function are as follows:

  1. Creating a window
  2. Creating a map from a file
  3. Process the events and quit if requests
  4. Update the viewer
  5. Display the viewer on the screen

The result will be as follows:

A usage example

Now that the map is done, we need to fill it with some entities.

lock icon The rest of the chapter is locked
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at $19.99/month. Cancel anytime