Adding TMX map support to the Chalo Engine


Tiled vs. how I prefer my map formats

I have never been a fan of the way Tiled saves its maps, but at the same time implementing my own map editor would be a large effort in and of itself.

When I create a map, I like to list out each tile on its own line with all its attributes: position, tile ID, is solid, etc., but when Tiled maps are saved or exported, it always saves it in a grid form like this:

<?xml version="1.0" encoding="UTF-8"?> <map version="1.2" tiledversion="1.3.2" orientation="orthogonal" renderorder="right-down" compressionlevel="0" width="20" height="12" tilewidth="20" tileheight="20" infinite="0" nextlayerid="5" nextobjectid="1">  <tileset firstgid="1" source="sheet-terrain.tsx">  <tileset firstgid="193" source="sheet-objects.tsx">  <layer id="1" name="FLOOR-LAYER" width="20" height="12">   <data encoding="csv"> 17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,16,17, 17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17,17 </data>  </layer>  <layer id="2" name="MIDDLE-LAYER1" width="20" height="12">   <data encoding="csv"> 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,66,66,66,66,66,66,66,66,66,66,66,66,56,66,66,66,66,66,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 </data>  </layer>  <layer id="3" name="MIDDLE-LAYER2" width="20" height="12">   <data encoding="csv"> 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,21,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 </data>  </layer>  <layer id="4" name="OBJECT-LAYER" width="20" height="12">   <data encoding="csv"> 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,215,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,241,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,220,0,0, 0,0,0,0,0,0,210,0,0,0,213,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,210,0,0,0,0,0,0,0,0,0,211,0,0,0,0, 0,0,0,0,0,0,0,209,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,230,0,0,0,0,0,0,0,0,0,206,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 </data>  </layer> </tileset></tileset></map>

Plus, it stores tile information separately in TSX files. Empty spots on the grid are represented by "0". And notice that the object layer has values like 241, 220, etc. - This is because I have two tilesets set up: The tileset, which is used as-is in the engine, and the object set, which just marks starting locations and directions for objects in the game and doesn't get loaded in 1:1 into the engine:


Because there are two tilesets, Tiled gives the second tileset an offset of 193 - corresponding to the firstgid value marked for the sheet-objects tileset. Gross.

It just really isn't my preferred format. Normally, I've written converters in Python or Lua or whatever to convert a Tiled map (or an exported Lua version) into the way I WANT the data to be. Since I'm working with other people on this game, however, I wanted to make it as easy as possible to build a map, throw it in the Maps folder, and test the game out, so instead the converter is internal to the engine.


Reading the TMX map format

You can view this code here: https://gitlab.com/moosadee/rawr-rinth/-/tree/main/source/chalo-engine/Maps

Basically, I had to spend a day first writing a TmxMap class that could parse and read a TMX map file, and I spent another day converting the data into a Chalo Map format that gives me the data I need in the way I need it.

I had to pull the basic map information from the file itself, in the map and tileset elements:

XmlElement mapData = mapXml.FindElementsWithPath( "map/" )[0];
m_mapVersion    = mapData.attributes["version"];
m_tiledVersion  = mapData.attributes["tiledversion"];
m_width         = Helper::StringToInt( mapData.attributes["width"] );
m_height        = Helper::StringToInt( mapData.attributes["height"] );
m_tileWidth     = Helper::StringToInt( mapData.attributes["tilewidth"] );
m_tileHeight    = Helper::StringToInt( mapData.attributes["tileheight"] );


In order to read the TMX map I had to add an XML Parser to my engine. Since I want to minimize the amount of external libraries I'm using (preferably just SFML), I wrote a simple parser myself. I can then access elements from the XML by a "path":

std::vector<xmlelement> dataData = mapXml.FindElementsWithPath( "map/layer/data/" );


Tiled map format also uses CSV format for the tile data itself, and I already have a CSV Parser, but I ended up just doing a string split (thanks to my Helper utility) and storing the tile data that way.

std::vector<std::string> values = Helper::Split( line, "," );             for ( auto& v : values )             {                 if ( v == "" ) { continue; }                 layer.data.push_back( Helper::StringToInt( v ) );             } </std::string>


Additionally, I had to add something to read in the TSX tileset data files because it also has information I need, so now my Maps folder has the TMX file and any related TSX files in there.

std::vector<XmlElement> tilesetData = mapXml.FindElementsWithPath( "map/tileset/" );
for ( auto& t : tilesetData )
{
    TmxTileset tt;
    tt.firstGid = Helper::StringToInt( t.attributes["firstgid"] );
    tt.tsxSource = t.attributes["source"];
    // Gotta load the tileset data, too. -_-
    XmlDocument tilesetDoc = XmlParser::Parse( path + tt.tsxSource );
    tilesetDoc.Debug();
    XmlElement tilesetData = tilesetDoc.FindElementsWithPath( "tileset/" )[0];
    tt.columns      = Helper::StringToInt( tilesetData.attributes["columns"] );
    tt.tileCount    = Helper::StringToInt( tilesetData.attributes["tileCount"] );
    tt.width        = Helper::StringToInt( tilesetData.attributes["width"] );
    tt.height       = Helper::StringToInt( tilesetData.attributes["height"] );
    tt.tileWidth    = Helper::StringToInt( tilesetData.attributes["columns"] );
    tt.tileHeight   = Helper::StringToInt( tilesetData.attributes["columns"] );
    XmlElement tilesetImageData = tilesetDoc.FindElementsWithPath( "tileset/image/" )[0];
    tt.imageWidth   = Helper::StringToInt( tilesetImageData.attributes["width"] );
    tt.imageHeight  = Helper::StringToInt( tilesetImageData.attributes["height"] );
    tt.imgSource    = tilesetImageData.attributes["source"];
    m_tilesets.push_back( tt );
}


Converting TMX map format to Chalo map format

Next I created a ChaloMap class to store the data as I wanted it. I reused an old Tile class from before, though I think it needs some cleaning up.

In the ChaloMap there's a ConvertFrom function that does all the work to take the data stored in the TmxMap and get all the data that *I* need on hand. Some of it is copying values over:

m_tileDimensions.x = tmxMap.m_tileWidth;     m_tileDimensions.y = tmxMap.m_tileHeight;     m_mapDimensions.x = tmxMap.m_width;     m_mapDimensions.y = tmxMap.m_height;


But some of it is getting the tileset information via the TSX files that was loaded in:

int terrainTileOffset = 0;
int objectTileOffset = 0;
TmxTileset terrainTileset;
TmxTileset objectTileset;
for ( auto& tileset : tmxMap.m_tilesets )
{
    if ( Helper::Contains( tileset.tsxSource, "object", false ) )
    {
        objectTileOffset = tileset.firstGid;
        objectTileset = tileset;
    }
    else //if ( Helper::Contains( tileset.tsxSource, "terrain", false ) )
    {
        terrainTileOffset = tileset.firstGid;
        terrainTileset = tileset;
        m_tilesetName = tileset.imgSource;
    }
}


And then separate logic for whether I'm loading a tile for the map or an object, which has its own sprite sheet and unique behavior. But the ChaloMap itself doesn't load in the objects, it just saves the object data to a vector, which can then be accessed by the GameState or somewhere else.

if ( Helper::Contains( layer.name, "object", false ) )
{
    // Object placement layer
    for ( size_t index = 0; index < layer.data.size(); index++ )
    {
        ChaloMapObjectData obj;
        obj.position.x = ( index % layer.width ) * tmxMap.m_tileWidth;
        obj.position.y = ( index / layer.width ) * tmxMap.m_tileHeight;
        obj.tmxId = layer.data[index] - objectTileOffset;
        if ( obj.tmxId == 0 )
        {
            continue;
        }
        if ( m_objectTypes.find( obj.tmxId ) == m_objectTypes.end() )
        {
            // No tile name found; skip it.
            continue;
        }
        obj.name = m_objectTypes[ obj.tmxId ];
        m_objects.push_back( obj );
    }
}


If it's a tile then we have to calculate the X and Y position of the tile based on its index in the Tiled Map, modulus the width of the tiled map (x) or integer division the width of the tiled map (y). I also need to get the dimensions of each tile, which is stored as part of the TSX files, AND I need the sprite sheet dimensions, which is also in the TSX files. The tiles also get added to separate layers, which specify the render order basically.

else
{
    ChaloMapLayer chaloLayer;
    // Terrain layer
    int zIndex = layer.id;
    chaloLayer.name = layer.name;
    chaloLayer.mapDimensions = sf::IntRect( 0, 0, tmxMap.m_width, tmxMap.m_height );
    for ( size_t index = 0; index < layer.data.size(); index++ )
    {
        ChaloTile newTile;
        newTile.m_dimensions.left   = 0;
        newTile.m_dimensions.top    = 0;
        newTile.m_dimensions.width  = tmxMap.m_tileWidth;
        newTile.m_dimensions.height = tmxMap.m_tileHeight;
        // Position of the tile on the map
        newTile.m_tmxIndex = index;
        newTile.m_position.x = (index % layer.width) * newTile.m_dimensions.width;
        newTile.m_position.y = (index / layer.width) * newTile.m_dimensions.height;
        // Position of the tile's coordinates on the tileset
        int tileId = layer.data[index] - terrainTileOffset;
        newTile.m_tmxTileId = tileId;
        int tileX = tileId % terrainTileset.columns;
        int tileY = tileId / terrainTileset.columns;
        newTile.m_frameRect.left    = tileX * newTile.m_dimensions.width;
        newTile.m_frameRect.top     = tileY * newTile.m_dimensions.height;
        newTile.m_frameRect.width   = newTile.m_dimensions.width;
        newTile.m_frameRect.height  = newTile.m_dimensions.height;
        newTile.m_sprite.setTexture( txTileset );
        newTile.m_sprite.setTextureRect( newTile.m_frameRect );
        newTile.m_sprite.setPosition( newTile.m_position );
        if ( chaloLayer.name == "FLOOR" )
        {
            newTile.m_canWalkOn = true;
        }
        else
        {
            newTile.m_canWalkOn = false;
        }
        // TODO: Put this in an external file
        if ( (tileX >= 0 || tileX <= 4) && tileY == 1 ) { newTile.m_name = "Floor" + Helper::ToString( tileX ); }
        if ( newTile.m_frameRect.left < 0 || newTile.m_frameRect.top < 0 )
        {
            if ( chaloLayer.name == "FLOOR" )
            {
                Logger::Out( "SKIPPING " + chaloLayer.name + " TILE " + Helper::ToString( index )
                    + ", TILEID: " + Helper::ToString( tileId )
                    + ", FRAME: " + SFMLHelper::RectangleToString( newTile.m_frameRect )
                    + ", TILEPOS: " + SFMLHelper::CoordinateToString( newTile.m_position )
                );
            }
            // Empty tile
            continue;
        }
        if ( newTile.m_canWalkOn == false )
        {
            m_solidTileRegions.push_back( newTile.GetCollisionRegion() );
        }
        chaloLayer.tiles.push_back( newTile );
    }
    m_layers.push_back( chaloLayer );
}


Turning object data into objects

At the moment I have the actual object loading in the GameState in an initialize function. I iterate through all the object data and create a new Object object. With my Object class I'm trying to keep things clean by using a "has-a" style design rather than an "is-a" style design. I might make different Object types later on, but for now the player, enemies, and objects are all Object types.

class Object
{
public:
    void Draw( sf::RenderTexture& window );
    void Update();
    void UpdateTurn( std::vector<Object>& allObjects, std::vector<sf::IntRect>& solidTiles );
    void Move();
    void Rotate( RotationDirection rotDirection );
    void Debug();
    void DrawDebug( sf::RenderTexture& window );
    sf::IntRect GetCollisionRect();
    bool CanObjectMoveInDirection( std::vector<Object>& allObjects, std::vector<sf::IntRect>& solidTiles, Direction direction );
    Animatable    prop_animatable;
    RawrCharacter prop_character;
    Movable       prop_movable;
    std::string   name;
private:
    static std::string s_className;
};


With this GameState init it will look at the type of object and fill out all the rest of the data from there - which sprite sheet texture to use, its movement speed, current direction, position, etc. And then it will add it to the <span class="n">std</span><span class="o">::</span><span class="n">vector</span><span class="o"><</span><span class="n">Object</span><span class="o">></span> <span class="n">m_allObjects</span><span class="p">;</span> structure.


Object behavior, collision, and movement

In the game, nothing will move until the player makes a move. This move transition is counted as 1 turn. Each Object has an UpdateTurn function where, depending on the object type, its behavior is defined. This could be separated out later, but we're not refactoring at this point, we're just getting stuff implemented.

void Object::UpdateTurn( std::vector<Object>& allObjects, std::vector<sf::IntRect>& solidTiles )
{
    prop_animatable.IncrementFrame();
    if      ( Helper::Contains( prop_character.type, "static robot" ) )
    {
    }
    else if ( Helper::Contains( prop_character.type, "rotating robot" ) )
    {
        // Turner rotates each 2 turns
        if ( prop_animatable.IsUpdateFrame() )
        {
            prop_animatable.Rotate( RotationDirection::CLOCKWISE );
        }
    }
    else if ( Helper::Contains( prop_character.type, "patrol robot" ) )
    {
        // 2dir moves forward (left/right or up/down) and turns around when hitting a wall
        // Map h as to handle this, for collision.
        if ( prop_animatable.IsUpdateFrame() )
        {
            if ( CanObjectMoveInDirection( allObjects, solidTiles, prop_animatable.facingDirection ) )
            {
                prop_movable.BeginMove( prop_animatable.facingDirection );
            }
            else
            {
                // Flip direction
                prop_animatable.FlipDirection();
            }
        }
    }
}


I also wrote a CanObjectMoveInDirection function, which was originally part of the GameState class, but I moved to the Object itself. This function is recursive in the case where, for instance, you might push a crate - the crate needs to figure out if it can move as well. If not, then neither yourself nor the crate can move.

I currently have an enum declared for CollisionBehavior:

enum class CollisionBehavior {     NOTHING,     BLOCK,     SHOVE,     PICKUP,     DAMAGE, };

And under the Character property class this CollisionBehavior is defined for each Object. I have not yet implemented "PICKUP" or "DAMAGE", but "BLOCK" and "SHOVE" are working.


Next steps

Either Tuesday or Wednesday I'm going to implement the ability to pick up and use items, which will also require me figuring out how to set up the HUD for the game. I also need to add in enemy vision lines (I wrote cones on the paper but it's not cones, it's just straight lines), as well as "rewinding" time to undo moves if you "die" (this was originally going to be the crank mechanic when we were designing for PlayDate, but we're just on PC now... but better than restarting the entire level.)

I also need to implement the key and switch / door opening systems, and basically the "end of level" flagging, so that we can all build a level and test the entire thing to completion.

That's what's on my immediate radar right now.


--Rachel

Leave a comment

Log in with itch.io to leave a comment.