Level Editor from a Misused Tilemap

With 15 minutes left on the clock, I needed a level editor, and I needed it fast. Over 3 hours in, I’d finally tamed the camera on my hastily modeled 3d barely-rigged player, and while he was walking around the room, I realized something pretty important was missing - barriers. The last “level editor” I’d used was when I opened up PikoPixel to “paint” in black and white the platforms for the NES game I was live coding, but I knew with the clock ticking that I wouldn’t have any time to fix the streaming tool (obs) scene bindings to correctly capture the window and write the importing code, so it had to be godot, and it had to be now.

If you ever find yourself in this sort of bind, here’s a trick I came up with that misuses a TileMap. The idea is simple:

  1. Make a 2d scene (Node2D), add a TileMap with the godot image, paint the level.
  2. Write a tool (EditorScript) to dump it out as JSON.
  3. Read it back in in your 3d scene (Spatial) and instantiate blocks according to the map.

Making the 2D scene

(2mins)

Keep the scene simple, just a Node2D as the parent, a TileMap as the child, and create a TileSet in the inspector. I didn’t want to spend any time crafting a tile, so I made the TileSet with one “Single Tile” of the default icon.png godot logo included in every project.

Scene:

Simple 2D Scene

To make better use of the screen, I adjusted the cell_size to 32x32 and matched the TileSet.

TileMap:

TileMap

TileSet:

TileSet

I painted a quick scene for testing, and jumped to the next step. Test Level

Writing the EditorScript to save the level

(7mins)

I’m not going to win any awards for this code, but the clock was running thin. My 3d room was 14 rows by 22 columns, so I iterated through the scene’s TileMap to read which tile was set in each location. Either the godot icon is set (cell_index == 0) or no tile is set (cell_index == -1), so I used the if to convert those to 1 for the block being there, 0 for the opposite.

func _run():
    print("leveldump tool ran")
    var tile_map : TileMap = get_scene().get_node("TileMap")

    var map : Array = []
    for row in range(14):
        var curr_row : Array = []
        for col in range(22):
            var cell_index : int = tile_map.get_cell(col, row)
            if cell_index == 0:
                curr_row.append(1)
            else:
                curr_row.append(0)
        map.append(curr_row)

    var level_file = File.new()
    level_file.open("level.json", File.WRITE)
    level_file.store_string(JSON.print(map))

Executing the tool (ctrl-shift-x) will show "leveldump tool ran" on the debug output and drop a file in the current directory named "level.json". A better tool would run this any time you save the 2d scene, but in a game jam, there’s rarely any time for “better”.

Materializing the level

(6mins)

Feel free to inspect "level.json" to make sure the data was serialized correctly - I just went straight to materializing it, since there wasn’t really any time left if it wasn’t right anyway.

After losing a bunch of time with the orthographic camera, I did this as simply as I could think - I made a rectangular solid pillar as a StaticBody that was taller than the player with a matching collider to prevent the player from walking through the block. I saved this as a separate scene (the godot equivalent of a unity3d “prefab”) and preloaded it before my ready script:

onready var block_scene : PackedScene = preload("res://Block.tscn")

Now with the block_scene available, I estimated where the top left x,z (y defaults to up off the floor in this scene) position and estimated the block size, something that could be read from the block_scene if there was more time:

func _ready():
    var block_offset = Vector3(-68, 0, -40)
    var block_size = Vector3(6.5, 0, 6.5)

I parsed the JSON file describing the level and instantiated a block everywhere there was a 1 and skipped the locations where there was a 0. I hardcoded the size due to time constraints. First a helper routine to unwrap the JSON:

func _load_level(level_filename : String) -> Array:
    var level_file = File.new()
    level_file.open(level_filename, File.READ)
    var json_parse_result = JSON.parse(level_file.get_as_text())
    var level = []
    if json_parse_result.error == OK:
        level = json_parse_result.result
    return level

Then we roll through all the hardcoded size locations in a double loop (need to extract the constants outside of the jam), if there’s a 1 there, instance a block and add it as a child under the "Obstacles" Spatial and place it by translating its location:

for row in range(14):
    for col in range(22):
        if level[row][col] == 1:
            var block = block_scene.instance()
            block.translate(block_offset + Vector3(
                block_size.x * col, 0, row * block_size.z))
            obstacles.add_child(block)

This is what it looks like in the editor: Materializing the Level

There are a lot of better ways to do this, but like I said, it’s the fastest I could come up with in 15 minutes. The jam was fun, even though I didn’t enter the final product, but one more trick available for the next hack!