RingOfRaces/addons/vnen.tiled_importer/tiled_map_reader.gd

1338 lines
43 KiB
GDScript

# The MIT License (MIT)
#
# Copyright (c) 2018 George Marques
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
@tool
extends RefCounted
# Constants for tile flipping
# http://doc.mapeditor.org/reference/tmx-map-format/#tile-flipping
const FLIPPED_HORIZONTALLY_FLAG = 0x80000000
const FLIPPED_VERTICALLY_FLAG = 0x40000000
const FLIPPED_DIAGONALLY_FLAG = 0x20000000
# XML Format reader
const TiledXMLToDictionary = preload("tiled_xml_to_dict.gd")
# Polygon vertices sorter
const PolygonSorter = preload("polygon_sorter.gd")
# Prefix for error messages, make easier to identify the source
const error_prefix = "Tiled Importer: "
# Properties to save the value in the metadata
const whitelist_properties = [
"backgroundcolor",
"compression",
"draworder",
"gid",
"height",
"imageheight",
"imagewidth",
"infinite",
"margin",
"name",
"orientation",
"probability",
"spacing",
"tilecount",
"tiledversion",
"tileheight",
"tilewidth",
"type",
"version",
"visible",
"width",
]
# All templates loaded, can be looked up by path name
var _loaded_templates = {}
# Maps each tileset file used by the map to it's first gid; Used for template parsing
var _tileset_path_to_first_gid = {}
func reset_global_memebers():
_loaded_templates = {}
_tileset_path_to_first_gid = {}
# Main function
# Reads a source file and gives back a scene
func build(source_path, options):
reset_global_memebers()
var map = read_file(source_path)
if typeof(map) == TYPE_INT:
return map
if typeof(map) != TYPE_DICTIONARY:
return ERR_INVALID_DATA
var err = validate_map(map)
if err != OK:
return err
var cell_size = Vector2(int(map.tilewidth), int(map.tileheight))
var map_mode = TileMap.MODE_SQUARE
var map_offset = TileMap.HALF_OFFSET_DISABLED
var map_pos_offset = Vector2()
var map_background = Color()
var cell_offset = Vector2()
if "orientation" in map:
match map.orientation:
"isometric":
map_mode = TileMap.MODE_ISOMETRIC
"staggered":
map_pos_offset.y -= cell_size.y / 2
match map.staggeraxis:
"x":
map_offset = TileMap.HALF_OFFSET_Y
cell_size.x /= 2.0
if map.staggerindex == "even":
cell_offset.x += 1
map_pos_offset.x -= cell_size.x
"y":
map_offset = TileMap.HALF_OFFSET_X
cell_size.y /= 2.0
if map.staggerindex == "even":
cell_offset.y += 1
map_pos_offset.y -= cell_size.y
"hexagonal":
# Godot maps are always odd and don't have an "even" setting. To
# imitate even staggering we simply start one row/column late and
# adjust the position of the whole map.
match map.staggeraxis:
"x":
map_offset = TileMap.HALF_OFFSET_Y
cell_size.x = int((cell_size.x + map.hexsidelength) / 2)
if map.staggerindex == "even":
cell_offset.x += 1
map_pos_offset.x -= cell_size.x
"y":
map_offset = TileMap.HALF_OFFSET_X
cell_size.y = int((cell_size.y + map.hexsidelength) / 2)
if map.staggerindex == "even":
cell_offset.y += 1
map_pos_offset.y -= cell_size.y
var tileset = build_tileset_for_scene(map.tilesets, source_path, options)
if typeof(tileset) != TYPE_OBJECT:
# Error happened
return tileset
var root = Node2D.new()
root.set_name(source_path.get_file().get_basename())
if options.save_tiled_properties:
set_tiled_properties_as_meta(root, map)
if options.custom_properties:
set_custom_properties(root, map)
var map_data = {
"options": options,
"map_mode": map_mode,
"map_offset": map_offset,
"map_pos_offset": map_pos_offset,
"map_background": map_background,
"cell_size": cell_size,
"cell_offset": cell_offset,
"tileset": tileset,
"source_path": source_path,
"infinite": bool(map.infinite) if "infinite" in map else false
}
for layer in map.layers:
err = make_layer(layer, root, root, map_data)
if err != OK:
return err
if options.add_background and "backgroundcolor" in map:
var bg_color = str(map.backgroundcolor)
if (!bg_color.is_valid_html_color()):
print_error("Invalid background color format: " + bg_color)
return root
map_background = Color(bg_color)
var viewport_size = Vector2(ProjectSettings.get("display/window/size/viewport_width"), ProjectSettings.get("display/window/size/viewport_height"))
var parbg = ParallaxBackground.new()
var parlayer = ParallaxLayer.new()
var colorizer = ColorRect.new()
parbg.scroll_ignore_camera_zoom = true
parlayer.motion_mirroring = viewport_size
colorizer.color = map_background
colorizer.size = viewport_size
colorizer.custom_minimum_size = viewport_size
parbg.name = "Background"
root.add_child(parbg)
parbg.owner = root
parlayer.name = "BackgroundLayer"
parbg.add_child(parlayer)
parlayer.owner = root
colorizer.name = "BackgroundColor"
parlayer.add_child(colorizer)
colorizer.owner = root
return root
# Creates a layer node from the data
# Returns an error code
func make_layer(layer, parent, root, data):
var err = validate_layer(layer)
if err != OK:
return err
# Main map data
var map_mode = data.map_mode
var map_offset = data.map_offset
var map_pos_offset = data.map_pos_offset
var cell_size = data.cell_size
var cell_offset = data.cell_offset
var options = data.options
var tileset = data.tileset
var source_path = data.source_path
var infinite = data.infinite
var opacity = float(layer.opacity) if "opacity" in layer else 1.0
var visible = bool(layer.visible) if "visible" in layer else true
if layer.type == "tilelayer":
var layer_size = Vector2(int(layer.width), int(layer.height))
var tilemap = TileMap.new()
tilemap.set_name(str(layer.name))
tilemap.cell_size = cell_size
tilemap.modulate = Color(1.0, 1.0, 1.0, opacity);
tilemap.visible = visible
tilemap.mode = map_mode
tilemap.cell_half_offset = map_offset
tilemap.format = 1
tilemap.cell_clip_uv = options.uv_clip
tilemap.cell_y_sort = true
tilemap.cell_tile_origin = TileMap.TILE_ORIGIN_BOTTOM_LEFT
tilemap.collision_layer = options.collision_layer
var offset = Vector2()
if "offsetx" in layer:
offset.x = int(layer.offsetx)
if "offsety" in layer:
offset.y = int(layer.offsety)
tilemap.position = offset + map_pos_offset
tilemap.tile_set = tileset
var chunks = []
if infinite:
chunks = layer.chunks
else:
chunks = [layer]
for chunk in chunks:
err = validate_chunk(chunk)
if err != OK:
return err
var chunk_data = chunk.data
if "encoding" in layer and layer.encoding == "base64":
if "compression" in layer:
chunk_data = decompress_layer_data(chunk.data, layer.compression, layer_size)
if typeof(chunk_data) == TYPE_INT:
# Error happened
return chunk_data
else:
chunk_data = read_base64_layer_data(chunk.data)
var count = 0
for tile_id in chunk_data:
var int_id = int(str(tile_id)) & 0xFFFFFFFF
if int_id == 0:
count += 1
continue
var flipped_h = bool(int_id & FLIPPED_HORIZONTALLY_FLAG)
var flipped_v = bool(int_id & FLIPPED_VERTICALLY_FLAG)
var flipped_d = bool(int_id & FLIPPED_DIAGONALLY_FLAG)
var gid = int_id & ~(FLIPPED_HORIZONTALLY_FLAG | FLIPPED_VERTICALLY_FLAG | FLIPPED_DIAGONALLY_FLAG)
var cell_x = cell_offset.x + chunk.x + (count % int(chunk.width))
var cell_y = cell_offset.y + chunk.y + int(count / chunk.width)
tilemap.set_cell(cell_x, cell_y, gid, flipped_h, flipped_v, flipped_d)
count += 1
if options.save_tiled_properties:
set_tiled_properties_as_meta(tilemap, layer)
if options.custom_properties:
set_custom_properties(tilemap, layer)
tilemap.set("editor/display_folded", true)
parent.add_child(tilemap)
tilemap.set_owner(root)
elif layer.type == "imagelayer":
var image = null
if layer.image != "":
image = load_image(layer.image, source_path, options)
if typeof(image) != TYPE_OBJECT:
# Error happened
return image
var pos = Vector2()
var offset = Vector2()
if "x" in layer:
pos.x = float(layer.x)
if "y" in layer:
pos.y = float(layer.y)
if "offsetx" in layer:
offset.x = float(layer.offsetx)
if "offsety" in layer:
offset.y = float(layer.offsety)
var sprite = Sprite2D.new()
sprite.set_name(str(layer.name))
sprite.centered = false
sprite.texture = image
sprite.visible = visible
sprite.modulate = Color(1.0, 1.0, 1.0, opacity)
if options.save_tiled_properties:
set_tiled_properties_as_meta(sprite, layer)
if options.custom_properties:
set_custom_properties(sprite, layer)
sprite.set("editor/display_folded", true)
parent.add_child(sprite)
sprite.position = pos + offset
sprite.set_owner(root)
elif layer.type == "objectgroup":
var object_layer = Node2D.new()
if options.save_tiled_properties:
set_tiled_properties_as_meta(object_layer, layer)
if options.custom_properties:
set_custom_properties(object_layer, layer)
object_layer.modulate = Color(1.0, 1.0, 1.0, opacity)
object_layer.visible = visible
object_layer.set("editor/display_folded", true)
parent.add_child(object_layer)
object_layer.set_owner(root)
if "name" in layer and not str(layer.name).is_empty():
object_layer.set_name(str(layer.name))
if not "draworder" in layer or layer.draworder == "topdown":
layer.objects.sort_custom(Callable(self,"object_sorter"))
for object in layer.objects:
if "template" in object:
var template_file = object["template"]
var template_data_immutable = get_template(remove_filename_from_path(data["source_path"]) + template_file)
if typeof(template_data_immutable) != TYPE_DICTIONARY:
# Error happened
print("Error getting template for object with id " + str(data["id"]))
continue
# Overwrite template data with current object data
apply_template(object, template_data_immutable)
set_default_obj_params(object)
if "point" in object and object.point:
var point = Marker2D.new()
if not "x" in object or not "y" in object:
print_error("Missing coordinates for point in object layer.")
continue
point.position = Vector2(float(object.x), float(object.y))
point.visible = bool(object.visible) if "visible" in object else true
object_layer.add_child(point)
point.set_owner(root)
if "name" in object and not str(object.name).is_empty():
point.set_name(str(object.name))
elif "id" in object and not str(object.id).is_empty():
point.set_name(str(object.id))
if options.save_tiled_properties:
set_tiled_properties_as_meta(point, object)
if options.custom_properties:
set_custom_properties(point, object)
elif not "gid" in object:
# Not a tile object
if "type" in object and object.type == "navigation":
# Can't make navigation objects right now
print_error("Navigation polygons aren't supported in an object layer.")
continue # Non-fatal error
var shape = shape_from_object(object)
if typeof(shape) != TYPE_OBJECT:
# Error happened
return shape
if "type" in object and object.type == "occluder":
var occluder = LightOccluder2D.new()
var pos = Vector2()
var rot = 0
if "x" in object:
pos.x = float(object.x)
if "y" in object:
pos.y = float(object.y)
if "rotation" in object:
rot = float(object.rotation)
occluder.visible = bool(object.visible) if "visible" in object else true
occluder.position = pos
occluder.rotation_degrees = rot
occluder.occluder = shape
if "name" in object and not str(object.name).is_empty():
occluder.set_name(str(object.name))
elif "id" in object and not str(object.id).is_empty():
occluder.set_name(str(object.id))
if options.save_tiled_properties:
set_tiled_properties_as_meta(occluder, object)
if options.custom_properties:
set_custom_properties(occluder, object)
object_layer.add_child(occluder)
occluder.set_owner(root)
else:
var body = Area2D.new() if object.type == "area" else StaticBody2D.new()
var offset = Vector2()
var collision
var pos = Vector2()
var rot = 0
if not ("polygon" in object or "polyline" in object):
# Regular shape
collision = CollisionShape2D.new()
collision.shape = shape
if shape is RectangleShape2D:
offset = shape.size
elif shape is CircleShape2D:
offset = Vector2(shape.radius, shape.radius)
elif shape is CapsuleShape2D:
offset = Vector2(shape.radius, shape.height)
if shape.radius > shape.height:
var temp = shape.radius
shape.radius = shape.height
shape.height = temp
collision.rotation_degrees = 90
shape.height *= 2
collision.position = offset
else:
collision = CollisionPolygon2D.new()
var points = null
if shape is ConcavePolygonShape2D:
points = []
var segments = shape.segments
for i in range(0, segments.size()):
if i % 2 != 0:
continue
points.push_back(segments[i])
collision.build_mode = CollisionPolygon2D.BUILD_SEGMENTS
else:
points = shape.points
collision.build_mode = CollisionPolygon2D.BUILD_SOLIDS
collision.polygon = points
collision.one_way_collision = object.type == "one-way"
if "x" in object:
pos.x = float(object.x)
if "y" in object:
pos.y = float(object.y)
if "rotation" in object:
rot = float(object.rotation)
body.set("editor/display_folded", true)
object_layer.add_child(body)
body.set_owner(root)
body.add_child(collision)
collision.set_owner(root)
if options.save_tiled_properties:
set_tiled_properties_as_meta(body, object)
if options.custom_properties:
set_custom_properties(body, object)
if "name" in object and not str(object.name).is_empty():
body.set_name(str(object.name))
elif "id" in object and not str(object.id).is_empty():
body.set_name(str(object.id))
body.visible = bool(object.visible) if "visible" in object else true
body.position = pos
body.rotation_degrees = rot
else: # "gid" in object
var tile_raw_id = int(str(object.gid)) & 0xFFFFFFFF
var tile_id = tile_raw_id & ~(FLIPPED_HORIZONTALLY_FLAG | FLIPPED_VERTICALLY_FLAG | FLIPPED_DIAGONALLY_FLAG)
var is_tile_object = tileset.tile_get_region(tile_id).get_area() == 0
var collisions = tileset.tile_get_shape_count(tile_id)
var has_collisions = collisions > 0 && object.has("type") && object.type != "sprite"
var sprite = Sprite2D.new()
var pos = Vector2()
var rot = 0
var scale = Vector2(1, 1)
sprite.texture = tileset.tile_get_texture(tile_id)
var texture_size = sprite.texture.get_size() if sprite.texture != null else Vector2()
if not is_tile_object:
sprite.region_enabled = true
sprite.region_rect = tileset.tile_get_region(tile_id)
texture_size = tileset.tile_get_region(tile_id).size
sprite.flip_h = bool(tile_raw_id & FLIPPED_HORIZONTALLY_FLAG)
sprite.flip_v = bool(tile_raw_id & FLIPPED_VERTICALLY_FLAG)
if "x" in object:
pos.x = float(object.x)
if "y" in object:
pos.y = float(object.y)
if "rotation" in object:
rot = float(object.rotation)
if texture_size != Vector2():
if "width" in object and float(object.width) != texture_size.x:
scale.x = float(object.width) / texture_size.x
if "height" in object and float(object.height) != texture_size.y:
scale.y = float(object.height) / texture_size.y
var obj_root = sprite
if has_collisions:
match object.type:
"area": obj_root = Area2D.new()
"kinematic": obj_root = CharacterBody2D.new()
"rigid": obj_root = RigidBody2D.new()
_: obj_root = StaticBody2D.new()
object_layer.add_child(obj_root)
obj_root.owner = root
obj_root.add_child(sprite)
sprite.owner = root
var shapes = tileset.tile_get_shapes(tile_id)
for s in shapes:
var collision_node = CollisionShape2D.new()
collision_node.shape = s.shape
collision_node.transform = s.shape_transform
if sprite.flip_h:
collision_node.position.x *= -1
collision_node.position.x -= cell_size.x
collision_node.scale.x *= -1
if sprite.flip_v:
collision_node.scale.y *= -1
collision_node.position.y *= -1
collision_node.position.y -= cell_size.y
obj_root.add_child(collision_node)
collision_node.owner = root
if "name" in object and not str(object.name).is_empty():
obj_root.set_name(str(object.name))
elif "id" in object and not str(object.id).is_empty():
obj_root.set_name(str(object.id))
obj_root.position = pos
obj_root.rotation_degrees = rot
obj_root.visible = bool(object.visible) if "visible" in object else true
obj_root.scale = scale
# Translate from Tiled bottom-left position to Godot top-left
sprite.centered = false
sprite.region_filter_clip_enabled = options.uv_clip
sprite.offset = Vector2(0, -texture_size.y)
if not has_collisions:
object_layer.add_child(sprite)
sprite.set_owner(root)
if options.save_tiled_properties:
set_tiled_properties_as_meta(obj_root, object)
if options.custom_properties:
if options.tile_metadata:
var tile_meta = tileset.get_meta("tile_meta")
if typeof(tile_meta) == TYPE_DICTIONARY and tile_id in tile_meta:
for prop in tile_meta[tile_id]:
obj_root.set_meta(prop, tile_meta[tile_id][prop])
set_custom_properties(obj_root, object)
elif layer.type == "group":
var group = Node2D.new()
var pos = Vector2()
if "x" in layer:
pos.x = float(layer.x)
if "y" in layer:
pos.y = float(layer.y)
group.modulate = Color(1.0, 1.0, 1.0, opacity)
group.visible = visible
group.position = pos
if options.save_tiled_properties:
set_tiled_properties_as_meta(group, layer)
if options.custom_properties:
set_custom_properties(group, layer)
if "name" in layer and not str(layer.name).is_empty():
group.set_name(str(layer.name))
group.set("editor/display_folded", true)
parent.add_child(group)
group.set_owner(root)
for sub_layer in layer.layers:
make_layer(sub_layer, group, root, data)
else:
print_error("Unknown layer type ('%s') in '%s'" % [str(layer.type), str(layer.name) if "name" in layer else "[unnamed layer]"])
return ERR_INVALID_DATA
return OK
func set_default_obj_params(object):
# Set default values for object
for attr in ["width", "height", "rotation", "x", "y"]:
if not attr in object:
object[attr] = 0
if not "type" in object:
object.type = ""
if not "visible" in object:
object.visible = true
# Makes a tileset from a array of tilesets data
# Since Godot supports only one TileSet per TileMap, all tilesets from Tiled are combined
func build_tileset_for_scene(tilesets, source_path, options):
var result = TileSet.new()
var err = ERR_INVALID_DATA
var tile_meta = {}
for tileset in tilesets:
var ts = tileset
var ts_source_path = source_path
if "source" in ts:
if not "firstgid" in tileset or not str(tileset.firstgid).is_valid_int():
print_error("Missing or invalid firstgid tileset property.")
return ERR_INVALID_DATA
ts_source_path = source_path.get_base_dir().plus_file(ts.source)
# Used later for templates
_tileset_path_to_first_gid[ts_source_path] = tileset.firstgid
if ts.source.get_extension().to_lower() == "tsx":
var tsx_reader = TiledXMLToDictionary.new()
ts = tsx_reader.read_tsx(ts_source_path)
if typeof(ts) != TYPE_DICTIONARY:
# Error happened
return ts
else: # JSON Tileset
var f = File.new()
err = f.open(ts_source_path, File.READ)
if err != OK:
print_error("Error opening tileset '%s'." % [ts.source])
return err
var test_json_conv = JSON.new()
test_json_conv.parse(f.get_as_text())
var json_res = test_json_conv.get_data()
if json_res.error != OK:
print_error("Error parsing tileset '%s' JSON: %s" % [ts.source, json_res.error_string])
return ERR_INVALID_DATA
ts = json_res.result
if typeof(ts) != TYPE_DICTIONARY:
print_error("Tileset '%s' is not a dictionary." % [ts.source])
return ERR_INVALID_DATA
ts.firstgid = tileset.firstgid
err = validate_tileset(ts)
if err != OK:
return err
var has_global_image = "image" in ts
var spacing = int(ts.spacing) if "spacing" in ts and str(ts.spacing).is_valid_int() else 0
var margin = int(ts.margin) if "margin" in ts and str(ts.margin).is_valid_int() else 0
var firstgid = int(ts.firstgid)
var columns = int(ts.columns) if "columns" in ts and str(ts.columns).is_valid_int() else -1
var image = null
var imagesize = Vector2()
if has_global_image:
image = load_image(ts.image, ts_source_path, options)
if typeof(image) != TYPE_OBJECT:
# Error happened
return image
imagesize = Vector2(int(ts.imagewidth), int(ts.imageheight))
var tilesize = Vector2(int(ts.tilewidth), int(ts.tileheight))
var tilecount = int(ts.tilecount)
var gid = firstgid
var x = margin
var y = margin
var i = 0
var column = 0
while i < tilecount:
var tilepos = Vector2(x, y)
var region = Rect2(tilepos, tilesize)
var rel_id = str(gid - firstgid)
result.create_tile(gid)
if has_global_image:
result.tile_set_texture(gid, image)
result.tile_set_region(gid, region)
if options.apply_offset:
result.tile_set_texture_offset(gid, Vector2(0, -tilesize.y))
elif not rel_id in ts.tiles:
gid += 1
continue
else:
var image_path = ts.tiles[rel_id].image
image = load_image(image_path, ts_source_path, options)
if typeof(image) != TYPE_OBJECT:
# Error happened
return image
result.tile_set_texture(gid, image)
if options.apply_offset:
result.tile_set_texture_offset(gid, Vector2(0, -image.get_height()))
if "tiles" in ts and rel_id in ts.tiles and "objectgroup" in ts.tiles[rel_id] \
and "objects" in ts.tiles[rel_id].objectgroup:
for object in ts.tiles[rel_id].objectgroup.objects:
var shape = shape_from_object(object)
if typeof(shape) != TYPE_OBJECT:
# Error happened
return shape
var offset = Vector2(float(object.x), float(object.y))
if options.apply_offset:
offset += result.tile_get_texture_offset(gid)
if "width" in object and "height" in object:
offset += Vector2(float(object.width) / 2, float(object.height) / 2)
if object.type == "navigation":
result.tile_set_navigation_polygon(gid, shape)
result.tile_set_navigation_polygon_offset(gid, offset)
elif object.type == "occluder":
result.tile_set_light_occluder(gid, shape)
result.tile_set_occluder_offset(gid, offset)
else:
result.tile_add_shape(gid, shape, Transform2D(0, offset), object.type == "one-way")
if options.custom_properties and options.tile_metadata and "tileproperties" in ts \
and "tilepropertytypes" in ts and rel_id in ts.tileproperties and rel_id in ts.tilepropertytypes:
tile_meta[gid] = get_custom_properties(ts.tileproperties[rel_id], ts.tilepropertytypes[rel_id])
if options.save_tiled_properties and rel_id in ts.tiles:
for property in whitelist_properties:
if property in ts.tiles[rel_id]:
if not gid in tile_meta: tile_meta[gid] = {}
tile_meta[gid][property] = ts.tiles[rel_id][property]
gid += 1
column += 1
i += 1
x += int(tilesize.x) + spacing
if (columns > 0 and column >= columns) or x >= int(imagesize.x) - margin or (x + int(tilesize.x)) > int(imagesize.x):
x = margin
y += int(tilesize.y) + spacing
column = 0
if str(ts.name) != "":
result.resource_name = str(ts.name)
if options.save_tiled_properties:
set_tiled_properties_as_meta(result, ts)
if options.custom_properties:
if "properties" in ts and "propertytypes" in ts:
set_custom_properties(result, ts)
if options.custom_properties and options.tile_metadata:
result.set_meta("tile_meta", tile_meta)
return result
# Makes a standalone TileSet. Useful for importing TileSets from Tiled
# Returns an error code if fails
func build_tileset(source_path, options):
var set = read_tileset_file(source_path)
if typeof(set) == TYPE_INT:
return set
if typeof(set) != TYPE_DICTIONARY:
return ERR_INVALID_DATA
# Just to validate and build correctly using the existing builder
set["firstgid"] = 0
return build_tileset_for_scene([set], source_path, options)
# Loads an image from a given path
# Returns a Texture2D
func load_image(rel_path, source_path, options):
var flags = options.image_flags if "image_flags" in options else Texture2D.FLAGS_DEFAULT
var embed = options.embed_internal_images if "embed_internal_images" in options else false
var ext = rel_path.get_extension().to_lower()
if ext != "png" and ext != "jpg":
print_error("Unsupported image format: %s. Use PNG or JPG instead." % [ext])
return ERR_FILE_UNRECOGNIZED
var total_path = rel_path
if rel_path.is_rel_path():
total_path = ProjectSettings.globalize_path(source_path.get_base_dir()).plus_file(rel_path)
total_path = ProjectSettings.localize_path(total_path)
var dir = Directory.new()
if not dir.file_exists(total_path):
print_error("Image not found: %s" % [total_path])
return ERR_FILE_NOT_FOUND
if not total_path.begins_with("res://"):
# External images need to be embedded
embed = true
var image = null
if embed:
image = ImageTexture.new()
image.load(total_path)
else:
image = ResourceLoader.load(total_path, "ImageTexture")
if image != null:
image.set_flags(flags)
return image
# Reads a file and returns its contents as a dictionary
# Returns an error code if fails
func read_file(path):
if path.get_extension().to_lower() == "tmx":
var tmx_to_dict = TiledXMLToDictionary.new()
var data = tmx_to_dict.read_tmx(path)
if typeof(data) != TYPE_DICTIONARY:
# Error happened
print_error("Error parsing map file '%s'." % [path])
# Return error or result
return data
# Not TMX, must be JSON
var file = File.new()
var err = file.open(path, File.READ)
if err != OK:
return err
var test_json_conv = JSON.new()
test_json_conv.parse(file.get_as_text())
var content = test_json_conv.get_data()
if content.error != OK:
print_error("Error parsing JSON: " + content.error_string)
return content.error
return content.result
# Reads a tileset file and return its contents as a dictionary
# Returns an error code if fails
func read_tileset_file(path):
if path.get_extension().to_lower() == "tsx":
var tmx_to_dict = TiledXMLToDictionary.new()
var data = tmx_to_dict.read_tsx(path)
if typeof(data) != TYPE_DICTIONARY:
# Error happened
print_error("Error parsing map file '%s'." % [path])
# Return error or result
return data
# Not TSX, must be JSON
var file = File.new()
var err = file.open(path, File.READ)
if err != OK:
return err
var test_json_conv = JSON.new()
test_json_conv.parse(file.get_as_text())
var content = test_json_conv.get_data()
if content.error != OK:
print_error("Error parsing JSON: " + content.error_string)
return content.error
return content.result
# Creates a shape from an object data
# Returns a valid shape depending on the object type (collision/occluder/navigation)
func shape_from_object(object):
var shape = ERR_INVALID_DATA
set_default_obj_params(object)
if "polygon" in object or "polyline" in object:
var vertices = PackedVector2Array()
if "polygon" in object:
for point in object.polygon:
vertices.push_back(Vector2(float(point.x), float(point.y)))
else:
for point in object.polyline:
vertices.push_back(Vector2(float(point.x), float(point.y)))
if object.type == "navigation":
shape = NavigationPolygon.new()
shape.vertices = vertices
shape.add_outline(vertices)
shape.make_polygons_from_outlines()
elif object.type == "occluder":
shape = OccluderPolygon2D.new()
shape.polygon = vertices
shape.closed = "polygon" in object
else:
if is_convex(vertices):
var sorter = PolygonSorter.new()
vertices = sorter.sort_polygon(vertices)
shape = ConvexPolygonShape2D.new()
shape.points = vertices
else:
shape = ConcavePolygonShape2D.new()
var segments = [vertices[0]]
for x in range(1, vertices.size()):
segments.push_back(vertices[x])
segments.push_back(vertices[x])
segments.push_back(vertices[0])
shape.segments = PackedVector2Array(segments)
elif "ellipse" in object:
if object.type == "navigation" or object.type == "occluder":
print_error("Ellipse shapes are not supported as navigation or occluder. Use polygon/polyline instead.")
return ERR_INVALID_DATA
if not "width" in object or not "height" in object:
print_error("Missing width or height in ellipse shape.")
return ERR_INVALID_DATA
var w = abs(float(object.width))
var h = abs(float(object.height))
if w == h:
shape = CircleShape2D.new()
shape.radius = w / 2.0
else:
# Using a capsule since it's the closest from an ellipse
shape = CapsuleShape2D.new()
shape.radius = w / 2.0
shape.height = h / 2.0
else: # Rectangle
if not "width" in object or not "height" in object:
print_error("Missing width or height in rectangle shape.")
return ERR_INVALID_DATA
var size = Vector2(float(object.width), float(object.height))
if object.type == "navigation" or object.type == "occluder":
# Those types only accept polygons, so make one from the rectangle
var vertices = PackedVector2Array([
Vector2(0, 0),
Vector2(size.x, 0),
size,
Vector2(0, size.y)
])
if object.type == "navigation":
shape = NavigationPolygon.new()
shape.vertices = vertices
shape.add_outline(vertices)
shape.make_polygons_from_outlines()
else:
shape = OccluderPolygon2D.new()
shape.polygon = vertices
else:
shape = RectangleShape2D.new()
shape.size = size / 2.0
return shape
# Determines if the set of vertices is convex or not
# Returns a boolean
func is_convex(vertices):
var size = vertices.size()
if size <= 3:
# Less than 3 verices can't be concave
return true
var cp = 0
for i in range(0, size + 2):
var p1 = vertices[(i + 0) % size]
var p2 = vertices[(i + 1) % size]
var p3 = vertices[(i + 2) % size]
var prev_cp = cp
cp = (p2.x - p1.x) * (p3.y - p2.y) - (p2.y - p1.y) * (p3.x - p2.x)
if i > 0 and sign(cp) != sign(prev_cp):
return false
return true
# Decompress the data of the layer
# Compression argument is a string, either "gzip" or "zlib"
func decompress_layer_data(layer_data, compression, map_size):
if compression != "gzip" and compression != "zlib":
print_error("Unrecognized compression format: %s" % [compression])
return ERR_INVALID_DATA
var compression_type = File.COMPRESSION_DEFLATE if compression == "zlib" else File.COMPRESSION_GZIP
var expected_size = int(map_size.x) * int(map_size.y) * 4
var raw_data = Marshalls.base64_to_raw(layer_data).decompress(expected_size, compression_type)
return decode_layer(raw_data)
# Reads the layer as a base64 data
# Returns an array of ints as the decoded layer would be
func read_base64_layer_data(layer_data):
var decoded = Marshalls.base64_to_raw(layer_data)
return decode_layer(decoded)
# Reads a PackedByteArray and returns the layer array
# Used for base64 encoded and compressed layers
func decode_layer(layer_data):
var result = []
for i in range(0, layer_data.size(), 4):
var num = (layer_data[i]) | \
(layer_data[i + 1] << 8) | \
(layer_data[i + 2] << 16) | \
(layer_data[i + 3] << 24)
result.push_back(num)
return result
# Set the custom properties into the metadata of the object
func set_custom_properties(object, tiled_object):
if not "properties" in tiled_object or not "propertytypes" in tiled_object:
return
var properties = get_custom_properties(tiled_object.properties, tiled_object.propertytypes)
for property in properties:
object.set_meta(property, properties[property])
# Get the custom properties as a dictionary
# Useful for tile meta, which is not stored directly
func get_custom_properties(properties, types):
var result = {}
for property in properties:
var value = null
if str(types[property]).to_lower() == "bool":
value = bool(properties[property])
elif str(types[property]).to_lower() == "int":
value = int(properties[property])
elif str(types[property]).to_lower() == "float":
value = float(properties[property])
elif str(types[property]).to_lower() == "color":
value = Color(properties[property])
else:
value = str(properties[property])
result[property] = value
return result
# Get the available whitelisted properties from the Tiled object
# And them as metadata in the Godot object
func set_tiled_properties_as_meta(object, tiled_object):
for property in whitelist_properties:
if property in tiled_object:
object.set_meta(property, tiled_object[property])
# Custom function to sort objects in an object layer
# This is done to support the "topdown" draw order, which sorts by 'y' coordinate
func object_sorter(first, second):
if first.y == second.y:
return first.id < second.id
return first.y < second.y
# Validates the map dictionary content for missing or invalid keys
# Returns an error code
func validate_map(map):
if not "type" in map or map.type != "map":
print_error("Missing or invalid type property.")
return ERR_INVALID_DATA
elif not "version" in map or int(map.version) != 1:
print_error("Missing or invalid map version.")
return ERR_INVALID_DATA
elif not "tileheight" in map or not str(map.tileheight).is_valid_int():
print_error("Missing or invalid tileheight property.")
return ERR_INVALID_DATA
elif not "tilewidth" in map or not str(map.tilewidth).is_valid_int():
print_error("Missing or invalid tilewidth property.")
return ERR_INVALID_DATA
elif not "layers" in map or typeof(map.layers) != TYPE_ARRAY:
print_error("Missing or invalid layers property.")
return ERR_INVALID_DATA
elif not "tilesets" in map or typeof(map.tilesets) != TYPE_ARRAY:
print_error("Missing or invalid tilesets property.")
return ERR_INVALID_DATA
if "orientation" in map and (map.orientation == "staggered" or map.orientation == "hexagonal"):
if not "staggeraxis" in map:
print_error("Missing stagger axis property.")
return ERR_INVALID_DATA
elif not "staggerindex" in map:
print_error("Missing stagger axis property.")
return ERR_INVALID_DATA
return OK
# Validates the tileset dictionary content for missing or invalid keys
# Returns an error code
func validate_tileset(tileset):
if not "firstgid" in tileset or not str(tileset.firstgid).is_valid_int():
print_error("Missing or invalid firstgid tileset property.")
return ERR_INVALID_DATA
elif not "tilewidth" in tileset or not str(tileset.tilewidth).is_valid_int():
print_error("Missing or invalid tilewidth tileset property.")
return ERR_INVALID_DATA
elif not "tileheight" in tileset or not str(tileset.tileheight).is_valid_int():
print_error("Missing or invalid tileheight tileset property.")
return ERR_INVALID_DATA
elif not "tilecount" in tileset or not str(tileset.tilecount).is_valid_int():
print_error("Missing or invalid tilecount tileset property.")
return ERR_INVALID_DATA
if not "image" in tileset:
for tile in tileset.tiles:
if not "image" in tileset.tiles[tile]:
print_error("Missing or invalid image in tileset property.")
return ERR_INVALID_DATA
elif not "imagewidth" in tileset.tiles[tile] or not str(tileset.tiles[tile].imagewidth).is_valid_int():
print_error("Missing or invalid imagewidth tileset property 1.")
return ERR_INVALID_DATA
elif not "imageheight" in tileset.tiles[tile] or not str(tileset.tiles[tile].imageheight).is_valid_int():
print_error("Missing or invalid imageheight tileset property.")
return ERR_INVALID_DATA
else:
if not "imagewidth" in tileset or not str(tileset.imagewidth).is_valid_int():
print_error("Missing or invalid imagewidth tileset property 2.")
return ERR_INVALID_DATA
elif not "imageheight" in tileset or not str(tileset.imageheight).is_valid_int():
print_error("Missing or invalid imageheight tileset property.")
return ERR_INVALID_DATA
return OK
# Validates the layer dictionary content for missing or invalid keys
# Returns an error code
func validate_layer(layer):
if not "type" in layer:
print_error("Missing or invalid type layer property.")
return ERR_INVALID_DATA
elif not "name" in layer:
print_error("Missing or invalid name layer property.")
return ERR_INVALID_DATA
match layer.type:
"tilelayer":
if not "height" in layer or not str(layer.height).is_valid_int():
print_error("Missing or invalid layer height property.")
return ERR_INVALID_DATA
elif not "width" in layer or not str(layer.width).is_valid_int():
print_error("Missing or invalid layer width property.")
return ERR_INVALID_DATA
elif not "data" in layer:
if not "chunks" in layer:
print_error("Missing data or chunks layer properties.")
return ERR_INVALID_DATA
elif typeof(layer.chunks) != TYPE_ARRAY:
print_error("Invalid chunks layer property.")
return ERR_INVALID_DATA
elif "encoding" in layer:
if layer.encoding == "base64" and typeof(layer.data) != TYPE_STRING:
print_error("Invalid data layer property.")
return ERR_INVALID_DATA
if layer.encoding != "base64" and typeof(layer.data) != TYPE_ARRAY:
print_error("Invalid data layer property.")
return ERR_INVALID_DATA
elif typeof(layer.data) != TYPE_ARRAY:
print_error("Invalid data layer property.")
return ERR_INVALID_DATA
if "compression" in layer:
if layer.compression != "gzip" and layer.compression != "zlib":
print_error("Invalid compression type.")
return ERR_INVALID_DATA
"imagelayer":
if not "image" in layer or typeof(layer.image) != TYPE_STRING:
print_error("Missing or invalid image path for layer.")
return ERR_INVALID_DATA
"objectgroup":
if not "objects" in layer or typeof(layer.objects) != TYPE_ARRAY:
print_error("Missing or invalid objects array for layer.")
return ERR_INVALID_DATA
"group":
if not "layers" in layer or typeof(layer.layers) != TYPE_ARRAY:
print_error("Missing or invalid layer array for group layer.")
return ERR_INVALID_DATA
return OK
func validate_chunk(chunk):
if not "data" in chunk:
print_error("Missing data chunk property.")
return ERR_INVALID_DATA
elif not "height" in chunk or not str(chunk.height).is_valid_int():
print_error("Missing or invalid height chunk property.")
return ERR_INVALID_DATA
elif not "width" in chunk or not str(chunk.width).is_valid_int():
print_error("Missing or invalid width chunk property.")
return ERR_INVALID_DATA
elif not "x" in chunk or not str(chunk.x).is_valid_int():
print_error("Missing or invalid x chunk property.")
return ERR_INVALID_DATA
elif not "y" in chunk or not str(chunk.y).is_valid_int():
print_error("Missing or invalid y chunk property.")
return ERR_INVALID_DATA
return OK
# Custom function to print error, to centralize the prefix addition
func print_error(err):
printerr(error_prefix + err)
func get_template(path):
# If this template has not yet been loaded
if not _loaded_templates.has(path):
# IS XML
if path.get_extension().to_lower() == "tx":
var parser = XMLParser.new()
var err = parser.open(path)
if err != OK:
print_error("Error opening TX file '%s'." % [path])
return err
var content = parse_template(parser, path)
if typeof(content) != TYPE_DICTIONARY:
# Error happened
print_error("Error parsing template map file '%s'." % [path])
return false
_loaded_templates[path] = content
# IS JSON
else:
var file = File.new()
var err = file.open(path, File.READ)
if err != OK:
return err
var test_json_conv = JSON.new()
test_json_conv.parse(file.get_as_text())
var json_res = test_json_conv.get_data()
if json_res.error != OK:
print_error("Error parsing JSON template map file '%s'." % [path])
return json_res.error
var result = json_res.result
if typeof(result) != TYPE_DICTIONARY:
print_error("Error parsing JSON template map file '%s'." % [path])
return ERR_INVALID_DATA
var object = result.object
if object.has("gid"):
if result.has("tileset"):
var ts_path = remove_filename_from_path(path) + result.tileset.source
var tileset_gid_increment = get_first_gid_from_tileset_path(ts_path) - 1
object.gid += tileset_gid_increment
_loaded_templates[path] = object
var dict = _loaded_templates[path]
var dictCopy = {}
for k in dict:
dictCopy[k] = dict[k]
return dictCopy
func parse_template(parser, path):
var err = OK
# Template root node shouldn't have attributes
var data = {}
var tileset_gid_increment = 0
data.id = 0
err = parser.read()
while err == OK:
if parser.get_node_type() == XMLParser.NODE_ELEMENT_END:
if parser.get_node_name() == "template":
break
elif parser.get_node_type() == XMLParser.NODE_ELEMENT:
if parser.get_node_name() == "tileset":
var ts_path = remove_filename_from_path(path) + parser.get_named_attribute_value_safe("source")
tileset_gid_increment = get_first_gid_from_tileset_path(ts_path) - 1
data.tileset = ts_path
if parser.get_node_name() == "object":
var object = TiledXMLToDictionary.parse_object(parser)
for k in object:
data[k] = object[k]
err = parser.read()
if data.has("gid"):
data["gid"] += tileset_gid_increment
return data
func get_first_gid_from_tileset_path(path):
for t in _tileset_path_to_first_gid:
if is_same_file(path, t):
return _tileset_path_to_first_gid[t]
return 0
static func get_filename_from_path(path):
var substrings = path.split("/", false)
var file_name = substrings[substrings.size() - 1]
return file_name
static func remove_filename_from_path(path):
var file_name = get_filename_from_path(path)
var stringSize = path.length() - file_name.length()
var file_path = path.substr(0,stringSize)
return file_path
static func is_same_file(path1, path2):
var file1 = File.new()
var err = file1.open(path1, File.READ)
if err != OK:
return err
var file2 = File.new()
err = file2.open(path2, File.READ)
if err != OK:
return err
var file1_str = file1.get_as_text()
var file2_str = file2.get_as_text()
if file1_str == file2_str:
return true
return false
static func apply_template(object, template_immutable):
for k in template_immutable:
# Do not overwrite any object data
if typeof(template_immutable[k]) == TYPE_DICTIONARY:
if not object.has(k):
object[k] = {}
apply_template(object[k], template_immutable[k])
elif not object.has(k):
object[k] = template_immutable[k]