1 /** 2 * Read and write data for <a href="mapeditor.org">Tiled</a> maps. 3 * Currently only supports JSON format. 4 * 5 * Authors: <a href="https://github.com/rcorre">rcorre</a> 6 * License: <a href="http://opensource.org/licenses/MIT">MIT</a> 7 * Copyright: Copyright © 2015, Ryan Roden-Corrent 8 */ 9 module dtiled.data; 10 11 import std.conv : to; 12 import std.file : exists; 13 import std.range : empty, front, retro; 14 import std..string : format; 15 import std.algorithm : find; 16 import std.exception : enforce; 17 import jsonizer; 18 19 /** 20 * Underlying type used to represent Tiles Global IDentifiers. 21 * Note that a GID of 0 is used to indicate the abscence of a tile. 22 */ 23 alias TiledGid = uint; 24 25 /// Flags set by Tiled in the guid field. Used to indicate mirroring and rotation. 26 enum TiledFlag : TiledGid { 27 none = 0x00000000, /// Tile is not flipped 28 flipDiagonal = 0x20000000, /// Tile is flipped diagonally 29 flipVertical = 0x40000000, /// Tile is flipped vertically (over x axis) 30 flipHorizontal = 0x80000000, /// Tile is flipped horizontally (over y axis) 31 all = flipHorizontal | flipVertical | flipDiagonal, /// bitwise `or` of all tile flags. 32 } 33 34 /// 35 unittest { 36 // this is the GID for a tile with tileset index 21 that was flipped horizontally 37 TiledGid gid = 2147483669; 38 // clearing the flip flags yields a gid that should map to a tileset index 39 assert((gid & ~TiledFlag.all) == 21); 40 // it is flipped horizontally 41 assert(gid & TiledFlag.flipHorizontal); 42 assert(!(gid & TiledFlag.flipVertical)); 43 assert(!(gid & TiledFlag.flipDiagonal)); 44 } 45 46 /// Top-level Tiled structure - encapsulates all data in the map file. 47 struct MapData { 48 mixin JsonizeMe; 49 50 /* Types */ 51 /// Map orientation. 52 enum Orientation { 53 orthogonal, /// rectangular orthogonal map 54 isometric, /// diamond-shaped isometric map 55 staggered /// rough rectangular isometric map 56 } 57 58 /** The order in which tiles on tile layers are rendered. 59 * From the docs: 60 * Valid values are right-down (the default), right-up, left-down and left-up. 61 * In all cases, the map is drawn row-by-row. 62 * (since 0.10, but only supported for orthogonal maps at the moment) 63 */ 64 enum RenderOrder : string { 65 rightDown = "right-down", /// left-to-right, top-to-bottom 66 rightUp = "right-up", /// left-to-right, bottom-to-top 67 leftDown = "left-down", /// right-to-left, top-to-bottom 68 leftUp = "left-up" /// right-to-left, bottom-to-top 69 } 70 71 /* Data */ 72 @jsonize(JsonizeOptional.no) { 73 @jsonize("width") int numCols; /// Number of tile columns 74 @jsonize("height") int numRows; /// Number of tile rows 75 @jsonize("tilewidth") int tileWidth; /// General grid size. Individual tiles sizes may differ. 76 @jsonize("tileheight") int tileHeight; /// ditto 77 Orientation orientation; /// Orthogonal, isometric, or staggered 78 LayerData[] layers; /// All map layers (tiles and objects) 79 TilesetData[] tilesets; /// All tile sets defined in this map 80 } 81 82 @jsonize(JsonizeOptional.yes) { 83 @jsonize("backgroundcolor") string backgroundColor; /// Hex-formatted background color (#RRGGBB) 84 @jsonize("renderorder") string renderOrder; /// Rendering direction (orthogonal only) 85 @jsonize("nextobjectid") int nextObjectId; /// Global counter across all objects 86 string[string] properties; /// Key-value property pairs on map 87 } 88 89 /* Functions */ 90 /** Load a Tiled map from a JSON file. 91 * Throws if no file is found at that path or if the parsing fails. 92 * Params: 93 * path = filesystem path to a JSON map file exported by Tiled 94 * Returns: The parsed map data 95 */ 96 static auto load(string path) { 97 enforce(path.exists, "No map file found at " ~ path); 98 auto map = readJSON!MapData(path); 99 100 // Tiled should export Tilesets in order of increasing GID. 101 // Double check this in debug mode, as things will break if this invariant doesn't hold. 102 debug { 103 import std.algorithm : isSorted; 104 assert(map.tilesets.isSorted!((a,b) => a.firstGid < b.firstGid), 105 "TileSets are not sorted by GID!"); 106 } 107 108 return map; 109 } 110 111 /** Save a Tiled map to a JSON file. 112 * Params: 113 * path = file destination; parent directory must already exist 114 */ 115 void save(string path) { 116 // Tilemaps must be exported sorted in order of firstGid 117 debug { 118 import std.algorithm : isSorted; 119 assert(tilesets.isSorted!((a,b) => a.firstGid < b.firstGid), 120 "TileSets are not sorted by GID!"); 121 } 122 123 path.writeJSON(this); 124 } 125 126 /** Fetch a map layer by its name. No check for layers with duplicate names is performed. 127 * Throws if no layer has a matching name (case-sensitive). 128 * Params: 129 * name = name of layer to find 130 * Returns: Layer matching name 131 */ 132 auto getLayer(string name) { 133 auto r = layers.find!(x => x.name == name); 134 enforce(!r.empty, "Could not find layer named %s".format(name)); 135 return r.front; 136 } 137 138 /** Fetch a tileset by its name. No check for layers with duplicate names is performed. 139 * Throws if no tileset has a matching name (case-sensitive). 140 * Params: 141 * name = name of tileset to find 142 * Returns: Tileset matching name 143 */ 144 auto getTileset(string name) { 145 auto r = tilesets.find!(x => x.name == name); 146 enforce(!r.empty, "Could not find tileset named %s".format(name)); 147 return r.front; 148 } 149 150 /** Fetch the tileset containing the tile a given GID. 151 * Throws if the gid is out of range for all tilesets 152 * Params: 153 * gid = gid of tile to find tileset for 154 * Returns: Tileset containing the given gid 155 */ 156 auto getTileset(TiledGid gid) { 157 gid = gid.cleanGid; 158 // search in reverse order, want the highest firstGid <= the given gid 159 auto r = tilesets.retro.find!(x => x.firstGid <= gid); 160 enforce(!r.empty, "GID %d is out of range for all tilesets".format(gid)); 161 return r.front; 162 } 163 164 /// 165 unittest { 166 MapData map; 167 map.tilesets ~= TilesetData(); 168 map.tilesets[0].firstGid = 1; 169 map.tilesets ~= TilesetData(); 170 map.tilesets[1].firstGid = 5; 171 map.tilesets ~= TilesetData(); 172 map.tilesets[2].firstGid = 12; 173 174 assert(map.getTileset(1) == map.tilesets[0]); 175 assert(map.getTileset(3) == map.tilesets[0]); 176 assert(map.getTileset(5) == map.tilesets[1]); 177 assert(map.getTileset(9) == map.tilesets[1]); 178 assert(map.getTileset(15) == map.tilesets[2]); 179 } 180 } 181 182 /** A layer of tiles within the map. 183 * 184 * A Map layer could be one of: 185 * Tile Layer: data is an array of guids that each map to some tile from a TilesetData 186 * Object Group: objects is a set of entities that are not necessarily tied to the grid 187 * Image Layer: This layer is a static image (e.g. a backdrop) 188 */ 189 struct LayerData { 190 mixin JsonizeMe; 191 192 /// Identifies what kind of information a layer contains. 193 enum Type { 194 tilelayer, /// One tileset index for every tile in the layer 195 objectgroup, /// One or more ObjectData 196 imagelayer /// TODO: try actually creating one of these 197 } 198 199 @jsonize(JsonizeOptional.no) { 200 @jsonize("width") int numCols; /// Number of tile columns. Identical to map width in Tiled Qt. 201 @jsonize("height") int numRows; /// Number of tile rows. Identical to map height in Tiled Qt. 202 string name; /// Name assigned to this layer 203 Type type; /// Category (tile, object, or image) 204 bool visible; /// whether layer is shown or hidden in editor 205 int x; /// Horizontal layer offset. Always 0 in Tiled Qt. 206 int y; /// Vertical layer offset. Always 0 in Tiled Qt. 207 } 208 209 // These entries exist only on object layers 210 @jsonize(JsonizeOptional.yes) { 211 TiledGid[] data; /// An array of tile GIDs. Only for `tilelayer` 212 ObjectData[] objects; /// An array of objects. Only on `objectgroup` layers. 213 string[string] properties; /// Optional key-value properties for this layer 214 float opacity; /// Visual opacity of all tiles in this layer 215 @jsonize("draworder") string drawOrder; /// Not documented by tiled, but may appear in JSON. 216 } 217 218 @property { 219 /// get the row corresponding to a position in the $(D data) or $(D objects) array. 220 auto idxToRow(size_t idx) { return idx / numCols; } 221 222 /// 223 unittest { 224 LayerData layer; 225 layer.numCols = 3; 226 layer.numRows = 2; 227 228 assert(layer.idxToRow(0) == 0); 229 assert(layer.idxToRow(1) == 0); 230 assert(layer.idxToRow(2) == 0); 231 assert(layer.idxToRow(3) == 1); 232 assert(layer.idxToRow(4) == 1); 233 assert(layer.idxToRow(5) == 1); 234 } 235 236 /// get the column corresponding to a position in the $(D data) or $(D objects) array. 237 auto idxToCol(size_t idx) { return idx % numCols; } 238 239 /// 240 unittest { 241 LayerData layer; 242 layer.numCols = 3; 243 layer.numRows = 2; 244 245 assert(layer.idxToCol(0) == 0); 246 assert(layer.idxToCol(1) == 1); 247 assert(layer.idxToCol(2) == 2); 248 assert(layer.idxToCol(3) == 0); 249 assert(layer.idxToCol(4) == 1); 250 assert(layer.idxToCol(5) == 2); 251 } 252 } 253 } 254 255 /** Represents an entity in an object layer. 256 * 257 * Objects are not necessarily grid-aligned, but rather have a position specified in pixel coords. 258 * Each object instance can have a `name`, `type`, and set of `properties` defined in the editor. 259 */ 260 struct ObjectData { 261 mixin JsonizeMe; 262 @jsonize(JsonizeOptional.no) { 263 int id; /// Incremental id - unique across all objects 264 int width; /// Width in pixels. Ignored if using a gid. 265 int height; /// Height in pixels. Ignored if using a gid. 266 string name; /// Name assigned to this object instance 267 string type; /// User-defined string 'type' assigned to this object instance 268 string[string] properties; /// Optional properties defined on this instance 269 bool visible; /// Whether object is shown. 270 int x; /// x coordinate in pixels 271 int y; /// y coordinate in pixels 272 float rotation; /// Angle in degrees clockwise 273 } 274 275 @jsonize(JsonizeOptional.yes) { 276 TiledGid gid; /// Identifies a tile in a tileset if this object is represented by a tile 277 } 278 } 279 280 /** 281 * A TilesetData maps GIDs (Global IDentifiers) to tiles. 282 * 283 * Each tileset has a range of GIDs that map to the tiles it contains. 284 * This range starts at `firstGid` and extends to the `firstGid` of the next tileset. 285 * The index of a tile within a tileset is given by tile.gid - tileset.firstGid. 286 * A tileset uses its `image` as a 'tile atlas' and may specify per-tile `properties`. 287 */ 288 struct TilesetData { 289 mixin JsonizeMe; 290 @jsonize(JsonizeOptional.no) { 291 string name; /// Name given to this tileset 292 string image; /// Image used for tiles in this set 293 int margin; /// Buffer between image edge and tiles (in pixels) 294 int spacing; /// Spacing between tiles in image (in pixels) 295 string[string] properties; /// Properties assigned to this tileset 296 @jsonize("firstgid") TiledGid firstGid; /// The GID that maps to the first tile in this set 297 @jsonize("tilewidth") int tileWidth; /// Maximum width of tiles in this set 298 @jsonize("tileheight") int tileHeight; /// Maximum height of tiles in this set 299 @jsonize("imagewidth") int imageWidth; /// Width of source image in pixels 300 @jsonize("imageheight") int imageHeight; /// Height of source image in pixels 301 } 302 303 @jsonize(JsonizeOptional.yes) { 304 /** Optional per-tile properties, indexed by the relative ID as a string. 305 * 306 * $(RED Note:) The ID is $(B not) the same as the GID. The ID is calculated relative to the 307 * firstgid of the tileset the tile belongs to. 308 * For example, if a tile has GID 25 and belongs to the tileset with firstgid = 10, then its 309 * properties are given by $(D tileset.tileproperties["15"]). 310 * 311 * A tile with no special properties will not have an index here. 312 * If no tiles have special properties, this field is not populated at all. 313 */ 314 string[string][string] tileproperties; 315 } 316 317 @property { 318 /// Number of tile rows in the tileset 319 int numRows() { return (imageHeight - margin * 2) / (tileHeight + spacing); } 320 321 /// Number of tile rows in the tileset 322 int numCols() { return (imageWidth - margin * 2) / (tileWidth + spacing); } 323 324 /// Total number of tiles defined in the tileset 325 int numTiles() { return numRows * numCols; } 326 } 327 328 /** 329 * Find the grid position of a tile within this tileset. 330 * 331 * Throws if $(D gid) is out of range for this tileset. 332 * Params: 333 * gid = GID of tile. Does not need to be cleaned of flags. 334 * Returns: 0-indexed row of tile 335 */ 336 int tileRow(TiledGid gid) { 337 return getIdx(gid) / numCols; 338 } 339 340 /** 341 * Find the grid position of a tile within this tileset. 342 * 343 * Throws if $(D gid) is out of range for this tileset. 344 * Params: 345 * gid = GID of tile. Does not need to be cleaned of flags. 346 * Returns: 0-indexed column of tile 347 */ 348 int tileCol(TiledGid gid) { 349 return getIdx(gid) % numCols; 350 } 351 352 /** 353 * Find the pixel position of a tile within this tileset. 354 * 355 * Throws if $(D gid) is out of range for this tileset. 356 * Params: 357 * gid = GID of tile. Does not need to be cleaned of flags. 358 * Returns: space between left side of image and left side of tile (pixels) 359 */ 360 int tileOffsetX(TiledGid gid) { 361 return margin + tileCol(gid) * (tileWidth + spacing); 362 } 363 364 /** 365 * Find the pixel position of a tile within this tileset. 366 * 367 * Throws if $(D gid) is out of range for this tileset. 368 * Params: 369 * gid = GID of tile. Does not need to be cleaned of flags. 370 * Returns: space between top side of image and top side of tile (pixels) 371 */ 372 int tileOffsetY(TiledGid gid) { 373 return margin + tileRow(gid) * (tileHeight + spacing); 374 } 375 376 /** 377 * Find the properties defined for a tile in this tileset. 378 * 379 * Throws if $(D gid) is out of range for this tileset. 380 * Params: 381 * gid = GID of tile. Does not need to be cleaned of flags. 382 * Returns: AA of key-value property pairs, or $(D null) if no properties defined for this tile. 383 */ 384 string[string] tileProperties(TiledGid gid) { 385 auto id = cleanGid(gid) - firstGid; // indexed by relative ID, not GID 386 auto res = id.to!string in tileproperties; 387 return res ? *res : null; 388 } 389 390 // clean the gid, adjust it to an index within this tileset, and throw if out of range 391 private auto getIdx(TiledGid gid) { 392 gid = gid.cleanGid; 393 auto idx = gid - firstGid; 394 395 enforce(idx >= 0 && idx < numTiles, 396 "GID %d out of range [%d,%d] for tileset %s" 397 .format( gid, firstGid, firstGid + numTiles - 1, name)); 398 399 return idx; 400 } 401 } 402 403 unittest { 404 // 3 rows, 3 columns 405 TilesetData tileset; 406 tileset.firstGid = 4; 407 tileset.tileWidth = tileset.tileHeight = 32; 408 tileset.imageWidth = tileset.imageHeight = 96; 409 tileset.tileproperties = [ "2": ["a": "b"], "3": ["c": "d"] ]; 410 411 void test(TiledGid gid, int row, int col, int x, int y, string[string] props) { 412 assert(tileset.tileRow(gid) == row , "row mismatch gid=%d".format(gid)); 413 assert(tileset.tileCol(gid) == col , "col mismatch gid=%d".format(gid)); 414 assert(tileset.tileOffsetX(gid) == x , "x mismatch gid=%d".format(gid)); 415 assert(tileset.tileOffsetY(gid) == y , "y mismatch gid=%d".format(gid)); 416 assert(tileset.tileProperties(gid) == props, "props mismatch gid=%d".format(gid)); 417 } 418 419 // gid , row , col , x , y , props 420 test(4 , 0 , 0 , 0 , 0 , null); 421 test(5 , 0 , 1 , 32 , 0 , null); 422 test(6 , 0 , 2 , 64 , 0 , ["a": "b"]); 423 test(7 , 1 , 0 , 0 , 32 , ["c": "d"]); 424 test(8 , 1 , 1 , 32 , 32 , null); 425 test(9 , 1 , 2 , 64 , 32 , null); 426 test(10 , 2 , 0 , 0 , 64 , null); 427 test(11 , 2 , 1 , 32 , 64 , null); 428 test(12 , 2 , 2 , 64 , 64 , null); 429 } 430 431 /** 432 * Clear the TiledFlag portion of a GID, leaving just the tile id. 433 * Params: 434 * gid = GID to clean 435 * Returns: A GID with the flag bits zeroed out 436 */ 437 TiledGid cleanGid(TiledGid gid) { 438 return gid & ~TiledFlag.all; 439 } 440 441 /// 442 unittest { 443 // normal tile, no flags 444 TiledGid gid = 0x00000002; 445 assert(gid.cleanGid == gid); 446 447 // normal tile, no flags 448 gid = 0x80000002; // tile with id 2 flipped horizontally 449 assert(gid.cleanGid == 0x2); 450 assert(gid & TiledFlag.flipHorizontal); 451 }