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(Jsonize.yes) {
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(Jsonize.opt) {
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(Jsonize.yes) {
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(Jsonize.opt) {
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(Jsonize.yes) {
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(Jsonize.opt) {
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(Jsonize.yes) {
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(Jsonize.opt) {
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 }