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
Rawr Rinth
A dino trying to take down capitalists. Nuff said.
Status | In development |
Authors | Moosadee, Murdoom, opusbot2000 |
Genre | Puzzle |
Tags | 2D, Dinosaurs, linux, single-screen |
More posts
- Just an art updateJun 21, 2022
- Creating menusJun 03, 2022
Leave a comment
Log in with itch.io to leave a comment.