LDtk Projects in Godot - Part 2: Tilesets

Loading LDtk Tilesets into Godot

Source code corresponding to this particular article can be found here: https://gitlab.com/letsmakegames/modules/godot/ldtk

For our game Moonlight Mountain I decided to try Godot for the first time. While it has some built in Level Editing tools, I decided to go with supporting LDtk projects instead. At the time of writing, LDtk reached it's first 1.0 milestone just 6 days ago! We were a big fan of the workflow, and in this series of articles, I want to talk a little about how we set it up, and how I went about building a custom importer. NOTE I'm new to both Godot and LDtk so this is more devlog than tutorial. Hope it helps!

In this article I want to briefly cover setting up a plugin / editor addon for Godot. Here are some relevant documentation links if you want to take a look:

We will need to create a basic LDtk file, and for that we need art assets. I turn to the amazing resource of OpenGameArt.org for this one:

LDtk Setup

Our first step is to create a project file to import. I won't go into all the details of how to setup and configure an LDtk project, feel free to reference the tool's solid documentation for that. What we will be focusing on, however, is the Tileset area in this portion.

Tilesets represent both a texture atlas of your tiles, as well as the specific regions of it that make up individual tiles. Let's create a tileset using the Tiny16 tileset (or any other tileset you like) and see what kinds of features we are going to need to support.

Tileset Settings

Tilesets in LDtk are not arbitrary regions within the atlas (in Godot you can specify arbitrary rects to describe tiles within an atlas). This limitation is also a simplification which will help us when processing the file. Instead of regions, we are given simply the texture size, and the Tile Layout.

  • Tiles are all SQUARE so a single scalar represents both the width and height (in my case the tiles are 16x16
  • Tiles can have additional spacing (space between tiles)
  • Tiles can also have additional padding (the margin around the edge of the tileset)

While more traditional, tightly packed spritesheets like the Tiny16 one, may cause some artifacting with the Godot TileMap later on due to floating point texture sampling area. One solution (like the one we used in Moonlight Mountain) is to enforce pixel perfect motion, which can cause jittering, but prevents the sub-pixel sampling that causes the error. Alternatively, you can add additional spacing around all your tiles to prevent sampling a neighbors pixels. This is a subject unto itself, but it's worth understanding why you might want to support spacing and padding rather than using packed textures exclusively.

There are two additional advanced features that are provided by LDtk, both of which we used in Moonlight Mountain).

Enum For Tile Marking

This field allows you to specify an Enum or enumeration of values to additionally tag your tiles. Using the Project Enums tab we created a TileAttribute enum and added 3 values to it:

  • A Collider tag - flagging this tile as always coming with a collision polygon
  • A OneWayCollider tag - flaggig this tile as not just needing a collision volume, but that it should be flagged as one_way which in Godot allows characters to pass through the collider from the bottom, but not from the top (useful in platformers like ours).
  • Lastly, we have a Climbable flag, which marks this tile as a surface which can be climbed. In Moonlight Mountain) this was used on all ladders, for example.

This is one of those situations I felt was so game specific that trying to use a generalized solution provided by other libraries would either be too specialized (ie: I could find one with colliders, maybe one way colliders, but probably not climbable), or too complex.

It is worth noting here that this approach makes a very important tradeoff that may not be obvious at first. By flagging a specific tile as collidable, it means every instance of that tile will have collision. If you want things like secret walls that look solid but can be walked through, or tiles that block only in certain circumstances, you'll have to come up with a solution separate from this to handle it. We'll come back to this idea later.

With the enums setup, we can start applying them to our tileset.

Back in the Project Tileset view we can now select our TileAttribute enum to be the Enum for tile marking. This allows us to select an enum value, and paint it onto our tileset. Whenever we load an instance of this tile in the tilemap, we will also get the additional enum tags we assign here.

You'll notice in this example, using a top down RPG tileset, the One Way collision and climbable tags may not be as useful, or may have a very different context in a different kind of game.

Custom Data

The last advanced field we used for tilesets was Custom Data, this is arbitrary string data we can associate with each tile (we used JSON). Just like painting the enums, we can select it from the dropdown where we select the enums.

It pops up with a custom editor.

We used this to specify custom collision geometry. By default, if no data was present, we assumed the entire tile was collidable. However, using this custom data field we could enter json that looked like this:

{
  "collider": [[0,5],[32,15],[32,32],[0,32]]
}

Here we are specifying a polygon where the top is slanted downards to the right, which we used on corner sloped tiles so the collision didn't look like the character was floating in the air when standing near the edge.

Complex Tileset

We should also have a tilset that exercises a more complex feature set to exercise the loading code we are about to write. To do this, I modified a tileset 1-Bit Platformer Pack by Kenney to create this:

Now we should have a padding of 1 and a spacing of 5.

After playing around here are my tilesets, I tried to get a good variation of types so we can really stress test all the features of our loader. Here is a zip file of the project I ended up with along with the textures I grabbed from the links provided above: ldtkExample.zip

JSON Specification

We will be referring to the JSON specification of LDtk locatd here specitically section 3.3 labeled Tileset Definition

When we take a look at the inside of the ldtk file (which is just a json file) we can find the section labeled "tilesets".

It's not super large so I'll paste it here, with two small edits:

  • I've removed the cachedPixel data sections, which are there for the editor and we can (and should) ignore it. There are undocumented sections throughout the project file, which serves as both asset for your engine as well as save file for the tool. Best practice is to use only the publically documented API to save yourself from headaches in the future.
  • I've truncated the tileIds sections, which are long lists of indexes into the tileset.

I created 2 tilesets, and one that comes by default with the project (sort of internally used). Lets take a look at them!

Internal Icons

{
    "__cWid": 16,
    "__cHei": 64,
    "identifier": "Internal_Icons",
    "uid": 3,
    "relPath": null,
    "embedAtlas": "LdtkIcons",
    "pxWid": 256,
    "pxHei": 1024,
    "tileGridSize": 16,
    "spacing": 0,
    "padding": 0,
    "tags": [],
    "tagsSourceEnumUid": null,
    "enumTags": [],
    "customData": [],
    "savedSelections": []
  },

You can see here that the simplest form of a tileset is represented by the internal icons used by the editor. It just describes the atlas location, the overall width and height of the texture, and the size of the tiles (tileGridSize). Spacing and padding are set to 0, and there are no enumTags or custom data.

DawnLike

{
    "__cWid": 61,
    "__cHei": 64,
    "identifier": "DawnLike",
    "uid": 6,
    "relPath": "textures/Environments.png",
    "embedAtlas": null,
    "pxWid": 976,
    "pxHei": 1024,
    "tileGridSize": 16,
    "spacing": 0,
    "padding": 0,
    "tags": [],
    "tagsSourceEnumUid": 2,
    "enumTags": [
      {
        "enumValueId": "Collider",
        "tileIds": [
          43,44,45, ... ,3090,3091
        ]
      },
      {
        "enumValueId": "OneWayCollider",
        "tileIds": []
      },
      { 
        "enumValueId": "Climbable", 
        "tileIds": [] 
      },
      {
        "enumValueId": "Occluder",
        "tileIds": [
          205,206, ... ,3877,3878
        ]
      }
    ],
    "customData": [],
    "savedSelections": []
}

Here you can see the only changes are the path to the texture is referred to as relPath letting us know that the texture path is relative to the project path. Additionally, you'll see that the enumTags now is populated with a list of tile ids that have been flagged with the "Collider" trait.

Note that in this tileset, no tiles have been marked as one way colliders, or climbable, but we do have some tagged as occluders, which is a trait that describes tiles that block light. (An optional feature we ended up not using in Moonlight Mountain, because I didn't like the way it looked, but I'll leave the code in in case you want to use it)

Platformer

{
"tilesets": [
  
  {
    "__cWid": 20,
    "__cHei": 20,
    "identifier": "Platformer",
    "uid": 5,
    "relPath": "textures/platformer.png",
    "embedAtlas": null,
    "pxWid": 349,
    "pxHei": 349,
    "tileGridSize": 16,
    "spacing": 1,
    "padding": 5,
    "tags": [],
    "tagsSourceEnumUid": 2,
    "enumTags": [ 
        {
          "enumValueId": "Collider",
          "tileIds": [ 83,84, ... ,398,399 ]
          },
          {
            "enumValueId": "OneWayCollider",
            "tileIds": [44,45,...,223,224]
          },
          {
            "enumValueId": "Climbable",
            "tileIds": [3,4,...,237,253],
          },
        {
          "enumValueId": "Occluder",
          "tileIds": [],
        }
    ],
    "customData": [
      { "tileId": 227, "data": "{ \"collider\": [[0,5], [5,0], [32,0], [32,32], [0,32]] }" },
      { "tileId": 229, "data": "{ \"collider\": [[0,0], [27,0], [32,5], [32,32], [0,32]] }" },
      { "tileId": 267, "data": "{ \"collider\": [[0,0], [32,0], [32,27], [27,32], [0, 32]] }" },
      { "tileId": 269, "data": "{ \"collider\": [[0,0], [32,0], [32,32], [5,32], [0, 27]] }" }
    ],
    "savedSelections": []
  },
}

Here is the most complex example. Like the previous one, we have colliders, but also tagged tiles for OneWayCollision as well as Climbable. You'll also see our custom data for four different tiles. The data section is an arbitrary string, but I've put a json string inside it that describes the colliders.

Basically they round the 4 corners of the bent pipe platform. We'll take a look at it after we import it.

Importing

First lets modify the ldtkProject file to add the methods that read the tileset section of the project file, and create godot TileSet objects.

Here's the full code sample with comments.

addons/lmg-ldtk/ldtkProject.gd
#
# (c) LetsMakeGames 2022
# http://www.letsmake.games
#

tool
extends Reference

#
# public api ##################################################################
#

func load_project(filepath):
    if not filepath is String:
        return null

    var jsonFile = File.new()
    jsonFile.open(filepath, File.READ)
    var json = JSON.parse(jsonFile.get_as_text()).result
    jsonFile.close()

    json['workingDir'] = filepath.get_base_dir()
    return json

#
# tilesets ////////////////////////////////////////////////////////////////////
#

func create_tilesets(proj):
    var result = {}
    for tsetdata in proj.defs.tilesets:
        var tileset = create_tileset(proj, tsetdata)
        if tileset != null:
            result[ tileset['uid'] ] = tileset
    
    return result

#
# -----------------------------------------------------------------------------
#

func create_tileset(proj, tsetdata):
    print('Creating tileset for ', tsetdata.identifier)
    # we require:
    #    - a valid project (json)
    #     - a valid loaded tsetdata (the portion of the project that refers to the 
    #      tileset wer are loading
    #    - the tileset should have a valid reference to a texture
    #        - note: not all layers will have textures, as we will see, but
    #          as far as I can tell it is reasonable to expect any TILE type
    #          layer to have a texture.
    if proj == null || tsetdata == null || tsetdata.relPath == null:
        return null

    var tileSize = tsetdata.tileGridSize

    #
    # create a godot tileset and load the texture referenced by the tileset
    # data.
    #
    var tileset = TileSet.new()
    var texturePath = '%s/%s' % [proj.workingDir, tsetdata.relPath]
    var texture = load(texturePath)
    var texdata = texture.get_data()

    
    #
    # collect all the tileIds that belong to each enum tag
    #
    var colliders = []    # tiles that are flagged as colliders
    var oneway = []        # tiles that are flagged as one way colliders
    var occlude = []    # tiles taht are flagged as light occluders

    for tag in tsetdata.enumTags:
        match tag.enumValueId:
            'Collider':  # add these tileIds to our colliders array
                colliders += tag.tileIds
            'OneWayCollider': 
                oneway += tag.tileIds
            'Occluder':
                occlude += tag.tileIds
    
    #
    # next load and JSON parse the custom data, this is where we put the custom
    # collision geometry.
    # NOTE: I should probably do a better job of error / fault checking.  Left
    # as an exercise for the reader =p
    var customData = {}
    for cdata in tsetdata.customData:
        customData[int(cdata.tileId)] = JSON.parse(cdata.data).result

    #
    # next we loop over every tile location in the tileset
    #
    var totalCells = tsetdata.__cWid * tsetdata.__cHei
    for tileIdx in range(0, totalCells):
        # get the rectangle that describes the location of this particular tile
        var tileRect = _tileset_idx_to_rect(tileIdx, tsetdata)

        # grab the pixels at that rect location
        var tileImg = texdata.get_rect(tileRect)

        # we ignore empty tiles
        if not tileImg.is_invisible():
            # add the tile to our godot tileset
            tileset.create_tile(tileIdx)
            tileset.tile_set_tile_mode(tileIdx, TileSet.SINGLE_TILE) # we don't support animated tiles
            tileset.tile_set_texture(tileIdx, texture) # set the texture
            tileset.tile_set_region(tileIdx, tileRect)

            # get the collision geometry from custom data, if we have any
            var colliderGeom = null
            if customData.has(tileIdx) and 'collider' in customData[tileIdx]:
                colliderGeom = customData[tileIdx].collider
            else: #otherwise us a box the width and height of tileSize
                colliderGeom = [[0, 0], [tileSize,0], [tileSize,tileSize], [0,tileSize]]

            # add collision and one way colliders if they are tagged
            var isCollider = tileIdx in colliders
            var isOneWay = tileIdx in oneway
            if isCollider || isOneWay:
                print(tileIdx, ' collider: ', colliderGeom, ' is one way? ', isOneWay)
                var colliderShape = _create_collision_shape_from_points(colliderGeom)
                tileset.tile_add_shape(tileIdx, colliderShape, Transform2D(), isOneWay)

            # add occluder geometry if we are tagged as an occluders
            if tileIdx in occlude:
                var occluderShape = _create_occluder_shape_form_points(colliderGeom)
                tileset.tile_set_light_occluder(tileIdx, occluderShape)

    #
    # next we return a dictionary containing the created godot tileset, as well
    # as some useful metadata future future steps, including the name and unique
    # id of this tileset
    #

    return {
        "name": tsetdata.identifier,
        "uid": tsetdata.uid,
        "tileset": tileset
    }


#
# internal functions ##########################################################
#

#
# this function takes a tile index and returns a rect describing the area the
# tile occupies within the tileset.  Takes into account padding and spacing
#
func _tileset_idx_to_rect(idx, tsetdata):
    # first we get the cell coordinate for this tile in the tileset.
    var cell = _tileset_idx_to_cell(idx, tsetdata)
    var pos = _tileset_cell_to_px(cell, tsetdata)

    # now we can build arect, starting at the pixel pos, and with a width and
    # height that matches the tileGridSize (the number of pixels each side of
    # a tile is)
    return Rect2(pos, Vector2(tsetdata.tileGridSize, tsetdata.tileGridSize))

#
# -----------------------------------------------------------------------------
#

#
# this function takes an index and a tsetdata (the json data for the tileset)
# and returns a coordinate that denotes the number of tile positions the specified
# tile, where the tile in the upper left ocrner of the texture is [0,0], and the tile
# in the bottom right corner is [numCols-1, numRows-1]
#
func _tileset_idx_to_cell(idx, tsetdata):
    
    var cellColCount = tsetdata.__cWid
    var cy = floor(idx / cellColCount)
    var cx = idx - cy * cellColCount
    
    return Vector2(cx, cy)

#
# -----------------------------------------------------------------------------
#

#
# this function takes a cell position and returns the pixel location by factoring
# in cell size, padding, and spacing of the tile
#
func _tileset_cell_to_px(cell, tsetdata):
    var pad = tsetdata.padding
    var tileSizeSpaced = tsetdata.tileGridSize + tsetdata.spacing

    return Vector2(
        pad + cell.x * tileSizeSpaced,
        pad + cell.y * tileSizeSpaced
    )

#
# -----------------------------------------------------------------------------
#

func _create_collision_shape_from_points(points):
    var shape = ConvexPolygonShape2D.new()
    var ps = []
    for point in points:
        ps.append(Vector2(point[0], point[1]))
    shape.set_points(PoolVector2Array(ps))

    return shape

#
# -----------------------------------------------------------------------------
#

func _create_occluder_shape_form_points(points):
    var shape = OccluderPolygon2D.new()
    var ps = []
    for point in points:
        ps.append(Vector2(point[0], point[1]))
    
    var poly = OccluderPolygon2D.new()
    poly.set_polygon(PoolVector2Array(ps))
    return poly

Now we need to modify the ldtkLoader.gd script to call our load tileset methods, and we temporarily use the ResourceSaver to save these tilesets to the project so we can inspect them.

addons/lmg-ldtk/ldtkLoader.gd
func import(sourceFile, savePath, options, platformVariants, genFiles):

    #
    # load ldtk project
    #
    var proj = LDtkProject.load_project(sourceFile)

    #
    # load tilesets
    #
    var tilesets = LDtkProject.create_tilesets(proj)
    for uid in tilesets:
        var tileset = tilesets[uid]
        var path = 'res://%s.res' % [tileset['name']]
        print('saving %s (%d) to %s' % [tileset['name'], tileset['uid'], path])
        ResourceSaver.save(path, tileset['tileset'])

    #
    # create result
    #
    var scene = PackedScene.new()

    var filename = "%s.%s" % [savePath, get_save_extension()]
    return ResourceSaver.save(filename, scene)

Known Issues

Partial Tiles

A source of bugs that caused me to loose a bit of time is that my code and LDtk handle textures that are not perfect multiples of the tilesize differently. LDtk treats these partial tiles as full tiles - getting an index. My method for calculating the number of tiles counts only the COMPLETE number of tiles, meaning that if you have a texture that has an additional pixels more than the tilesize + padding + spacing would use, the indices between LDtk and my code won't match up. I will probably fix this at some point, but it's part of a larget change I'm currently making (integrating WAT unit tests)

Occluder Using Collider Geometry

I guess you might want different geometry for lighting than you do for collision. I didn't use occlusion but it would be easy enough to add another field in the custom data json to control where the occluder geometry comes from, (something new, use the collision geometry, use the tile dimensions - for example)

Conclusion

This will successfully create Godot TileSet's for each LDtk Tileset in the project file. Next, we will look at parsing levels, and creating tilemaps that use these tilesets to render maps to the screen.