diff --git a/README.md b/README.md index 8551356..ebe37f8 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,24 @@ Legend: ticked = done, unticked = to do \* The Godot tileset editor supports only Rectangle and Polygon. That's Tiled are supported and are converted to polygons in Godot. +## Supported entity types + +Creating entities with these types will result in specific nodes to be created : + +- type `Area2D` + + Creates an `Area2D` node in the scene, containing a `CollisionShape2D` with the rectangle set in the tiled map. + + You can add `collision_layer` and `collision_mask` integer custom properties to set these properties for Godot. + +- type `Node2D` + + Creates an empty `Node2D` at the specified position. Can be useful for defining spawn points for example. + +If present, the `groups` custom string property will add the generated entity to the specified Godot scene groups. Accepts multiple groups via comma separation: `Group1, Group2`. + ## Long term plans + I'm making a 2D platformer and I'm gonna focus on these needs for now. Generally, I would like to support everything Tiled offers because it's a very good level editor. diff --git a/export_to_godot_tilemap.js b/export_to_godot_tilemap.js index a20c67e..5bf0602 100644 --- a/export_to_godot_tilemap.js +++ b/export_to_godot_tilemap.js @@ -10,7 +10,9 @@ class GodotTilemapExporter { this.tileOffset = 65536; this.tileMapsString = ""; this.tilesetsString = ""; + this.subResourcesString = ""; this.extResourceId = 0; + this.subResourceId = 0; /** * Tiled doesn't have tileset ID so we create a map @@ -35,6 +37,28 @@ class GodotTilemapExporter { console.info(`Tilemap exported successfully to ${this.fileName}`); } + /** + * Adds a new subresource to the genrated file + * + * @param {string} type the type of subresource + * @param {object} contentProperties key:value map of properties + * @returns {int} the created sub resource id + */ + addSubResource(type, contentProperties) { + const id = this.subResourceId++; + + this.subResourcesString += ` + +[sub_resource type="${type}" id=${id}] +`; + removeUndefined(contentProperties); + for (const [key, value] of Object.entries(contentProperties)) { + this.subResourcesString += stringifyKeyValue(key, value, false, true) + '\n'; + } + + return id; + } + /** * Generate a string with all tilesets in the map. * Godot allows only one tileset per tilemap so if you use more than one tileset per layer it's n ot going to work. @@ -76,14 +100,22 @@ class GodotTilemapExporter { if (!ld.isEmpty) { const tileMapName = idx === 0 ? layer.name || "TileMap " + i : ld.tileset.name || "TileMap " + i + "_" + idx; this.mapLayerToTileset(layer.name, ld.tilesetID); - this.tileMapsString += this.getTileMapTemplate(tileMapName, ld.tilesetID, ld.poolIntArrayString, ld.parent, layer.map.tileWidth, layer.map.tileHeight); + this.tileMapsString += this.getTileMapTemplate(tileMapName, ld.tilesetID, ld.poolIntArrayString, ld.parent, layer.map.tileWidth, layer.map.tileHeight, layer.property("groups")); } } } else if (layer.isObjectLayer) { - this.tileMapsString += ` - -[node name="${layer.name}" type="Node2D" parent="."]`; + // create layer + this.tileMapsString += stringifyNode({ + name: layer.name, + type: "Node2D", + parent: ".", + groups: splitCommaSeparated(layer.property("groups")) + }); + + // add entities for (const object of layer.objects) { + const groups = splitCommaSeparated(object.property("groups")); + if (object.tile) { let tilesetsIndexKey = object.tile.tileset.name + "_Image"; let textureResourceId = 0; @@ -103,13 +135,54 @@ class GodotTilemapExporter { let objectPositionX = object.x + (object.tile.width / 2); let objectPositionY = object.y - (object.tile.height / 2); - this.tileMapsString += ` - -[node name="${object.name}" type="Sprite" parent="${layer.name}"] -position = Vector2( ${objectPositionX}, ${objectPositionY} ) -texture = ExtResource( ${textureResourceId} ) -region_enabled = true -region_rect = Rect2( ${tileOffset.x}, ${tileOffset.y}, ${object.tile.width}, ${object.tile.height} )`; + this.tileMapsString += stringifyNode({ + name: object.name, + type: "Sprite", + parent: layer.name + }, { + position: `Vector2( ${objectPositionX}, ${objectPositionY} )`, + texture: `ExtResource( ${textureResourceId} )`, + region_enabled: true, + region_rect: `Rect2( ${tileOffset.x}, ${tileOffset.y}, ${object.tile.width}, ${object.tile.height} )` + }); + } else if (object.type == "Area2D" && object.width && object.height) { + // Creates an Area2D node with a rectangle shape inside + // Does not support rotation + const width = object.width / 2; + const height = object.height / 2; + const objectPositionX = object.x + width; + const objectPositionY = object.y + height; + + this.tileMapsString += stringifyNode({ + name: object.name, + type: "Area2D", + parent: layer.name, + groups: groups + }, { + collision_layer: object.property("collision_layer"), + collision_mask: object.property("collision_mask") + }); + + const shapeId = this.addSubResource("RectangleShape2D", { + extents: `Vector2( ${width}, ${height} )` + }); + this.tileMapsString += stringifyNode({ + name: "CollisionShape2D", + type: "CollisionShape2D", + parent: `${layer.name}/${object.name}` + }, { + shape: `SubResource( ${shapeId} )`, + position: `Vector2( ${objectPositionX}, ${objectPositionY} )`, + }); + } else if (object.type == "Node2D") { + this.tileMapsString += stringifyNode({ + name: object.name, + type: "Node2D", + parent: layer.name, + groups: groups + }, { + position: `Vector2( ${object.x}, ${object.y} )` + }); } } } @@ -347,9 +420,12 @@ region_rect = Rect2( ${tileOffset.x}, ${tileOffset.y}, ${object.tile.width}, ${o * @returns {string} */ getSceneTemplate() { - return `[gd_scene load_steps=2 format=2] + const loadSteps = 2 + this.subResourceId; + + return `[gd_scene load_steps=${loadSteps} format=2] ${this.tilesetsString} +${this.subResourcesString} [node name="Node2D" type="Node2D"] ${this.tileMapsString} `; @@ -370,14 +446,20 @@ ${this.tileMapsString} * Template for a tilemap node * @returns {string} */ - getTileMapTemplate(tileMapName, tilesetID, poolIntArrayString, parent = ".", tileWidth = 16, tileHeight = 16) { - return `[node name="${tileMapName}" type="TileMap" parent="${parent}"] -tile_set = ExtResource( ${tilesetID} ) -cell_size = Vector2( ${tileWidth}, ${tileHeight} ) -cell_custom_transform = Transform2D( 16, 0, 0, 16, 0, 0 ) -format = 1 -tile_data = PoolIntArray( ${poolIntArrayString} ) -`; + getTileMapTemplate(tileMapName, tilesetID, poolIntArrayString, parent = ".", tileWidth = 16, tileHeight = 16, groups = undefined) { + groups = splitCommaSeparated(groups); + return stringifyNode({ + name: tileMapName, + type: "TileMap", + parent: parent, + groups: groups + }, { + tile_set: `ExtResource( ${tilesetID} )`, + cell_size: `Vector2( ${tileWidth}, ${tileHeight} )`, + cell_custom_transform: `Transform2D( 16, 0, 0, 16, 0, 0 )`, + format: "1", + tile_data: `PoolIntArray( ${poolIntArrayString} )` + }); } mapLayerToTileset(layerName, tilesetID) { diff --git a/utils.js b/utils.js index a97684f..d89eb3f 100644 --- a/utils.js +++ b/utils.js @@ -47,3 +47,73 @@ function getTilesetColumns(tileset) { // so we need to return as Math.floor to avoid throwing off the tile indices. return Math.floor(calculatedColumnCount); } + +/** + * @param {string} str comma separated items + */ +function splitCommaSeparated(str) { + if (!str) { + return undefined; + } + return str.split(',').map(s => s.trim()); +} + +/** + * Removes any undefined value with its key from an object + * @param {object} obj + */ +function removeUndefined(obj) { + Object.keys(obj).forEach(key => obj[key] === undefined && delete obj[key]) +} + + +/** + * Translates key values defining a godot scene node to the expected TSCN format output + * Passed keys must be strings. Values can be arrays (e.g. for groups) + * + * @param {object} nodeProperties pair key/values for the "node" properties + * @param {object} contentProperties pair key/values for the content properties + * @return {string} TSCN scene node like so : + * ``` + * [node key="value"] + * content_key = AnyValue + * ``` + */ +function stringifyNode(nodeProperties, contentProperties = {}) { + // remove undefined values from objects + removeUndefined(nodeProperties); + removeUndefined(contentProperties); + + let str = '\n'; + str += '[node'; + for (const [key, value] of Object.entries(nodeProperties)) { + str += ' ' + this.stringifyKeyValue(key, value, true, false); + } + str += ']\n'; + for (const [key, value] of Object.entries(contentProperties)) { + str += this.stringifyKeyValue(key, value, false, true) + '\n'; + } + + return str; +} + +/** + * Processes a key/value pair for a TSCN node + * + * @param {string} key + * @param {string|array} value + * @param {bool} quote + * @param {bool} spaces + */ +function stringifyKeyValue(key, value, quote, spaces) { + // flatten arrays + if (Array.isArray(value)) { + value = '[\n"' + value.join('","') + '",\n]'; + } else if (quote) { + value = `"${value}"`; + } + if (!spaces) { + return `${key}=${value}`; + } + return `${key} = ${value}`; +} \ No newline at end of file