Welcome to 2025, everyone! The last month or so has been a pretty busy time for me; I’ve just finished moving out of my old apartment and into a new condo. So I’ve spent a lot of time packing boxes, unpacking boxes, painting, and all sorts of other DIY stuff. I even had my dad fly out and we spent a week running cat 6A ethernet cable to every room in the house (I’ll make a post about that adventure eventually as well).
Level Editor Scene Design π
I left off last time right after finishing the level saving and loading code. But a level editor isn’t a level editor unless you can actually edit levels, so I still had my work cut out for me. This first thing I had to do was create an actual scene for the level editor. In the design phase I thought a lot about space considerations. Since there is no moving camera for the game, everything in the level has to fit within a single screen. In the normal game, some levels have a text box that covers the lower portion of the screen, so I decided that I would put most of the new UI elements in the bottom to effectively use that space. You can see this in the final product below.
I knew from the beginning that I would have an array of “tools” like you would have in an something like GIMP or Photoshop. I figured there would be one button for each unique element in the game. Since there aren’t that many of them, it wouldn’t be that cluttered. Before I made buttons for everything I wanted to make a minimum viable product as soon as possible, so I only made buttons for normal tiles, start tiles, and finish pads.
I made a custom button class for these, since I wanted them to include text and icons. I then put the three buttons into a ButtonGroup so that I could make them mutually exclusive. Finally, I made a custom pressed signal that I emitted with an extra argument corresponding to the button that was pressed. The currently selected “tool” was put into a variable called object_to_place
. The signal handler in the level editor scene is detailed below.
enum {
NOTHING = 0,
NORMAL_TILE,
START_TILE,
FINISH_PAD,
}
## The object that we are placing
var object_to_place: int = NOTHING
# Handles the custom pressed signal from our tool select button.
func button_toggled(toggled_on: bool, idx: int) -> void:
free_current_object()
if toggled_on:
object_to_place = idx
allocate_object()
else:
object_to_place = NOTHING
Handling Input and Placing Tiles π
Now that the user could select a tool, I needed to implement how those tools interact with the level. Namely, when the user clicks an empty tile and they are using the normal tile tool, a normal tile should be placed in the level where they clicked. I handled this using the _gui_input method for Control
nodes.
func _gui_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
var mouse_pos := get_local_mouse_position()
if not LEVEL_BOUNDING_BOX.has_point(mouse_pos):
ground_preview_tile_map.clear()
wire_preview_tile_map.clear()
return
var grid_coords = ground_tile_map.local_to_map(mouse_pos)
if grid_coords != mouseover_tile:
mouse_entered_tile(grid_coords, mouseover_tile)
mouseover_tile = grid_coords
elif event is InputEventMouseButton:
var mouse_pos := get_local_mouse_position()
if not LEVEL_BOUNDING_BOX.has_point(mouse_pos):
ground_preview_tile_map.clear()
wire_preview_tile_map.clear()
return
if event.pressed and event.button_index == MOUSE_BUTTON_LEFT:
place_object(mouseover_tile)
I kept track of when the mouse enters a tile, even if it’s not being pressed, for reasons we’ll get into later. Most of the magic happens in place_object()
, which in turn places a tile within the level at the grid coordinates that the mouse is on top of. And with that, the basic flow of clicking the mouse to placing a tile was complete. Start tiles behave almost identically to normal tiles, except there’s an extra argument to the place_tile()
function indicating that we’re placing a start tile.
Because the level is a blank slate, and there aren’t any grid lines to guide the user, I decided that a preview feature would be required. This is why we keep track of the mouse position even when it’s not clicking. To accomplish having a preview, I made a second TileMapLayer instance, with the same tile set as the normal ground tile map, but with the modulate set to be partially transparent. That way, it’s clear to the user where they’re clicking, while keeping the actual tiles and the preview tiles disambiguated.
# Called when the mouse moves into a tile and the left mouse button is not pressed.
func preview_object(coords: Vector2i)-> void:
if object_to_place == NORMAL_TILE:
ground_preview_tile_map.clear()
level.place_tile(coords, true) # preview=true, start_tile=false
elif object_to_place == START_TILE:
ground_preview_tile_map.clear()
level.place_tile(coords, true, true) #preview=true, start_tile=true
# Called when the user clicks
func place_object(coords: Vector2i) -> void:
if object_to_place == NORMAL_TILE:
level.place_tile(coords)
ground_preview_tile_map.clear()
elif object_to_place == START_TILE:
level.place_tile(coords, false, true)
ground_preview_tile_map.clear()
tile_button_group.get_pressed_button().button_pressed = false
As you can see, the preview tile map is cleared very often. Since it will only ever contain the one preview tile, that is safe to do without losing any other data. THat’s another advantage of keeping the preview separate.
I implemented the placing of finish pads (which we will cover along with other objects in part 3), and had my first ever real demonstration. You can barely see the difference in the transparency between the preview and normal tiles (I’ve since turned that up), but it’s definitely there.
I glossed over a lot of the plumbing here: I had to modify a bunch of internal code to allow playing the level from the editor. This included, of course, saving and loading the level to connect all the signals from the objects. But this was the first time I had something to show for all the work I had done up to this point, so I proudly sent it to the team group chat.
Dragging and Erasing π
One thing I quickly realized was that individually placing every tile was going to be a real pain if you wanted to create larger levels. So right after I made that video I worked on the dragging mechanic, which ended up being pretty simple. All I had to do was edit the mouse_entered_tile()
function. The comment is pretty self-explanatory.
func mouse_entered_tile(current: Vector2i, prev: Vector2i) -> void:
if Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT):
# If the mouse button is down when we enter the tile, treat it as a click.
place_object(current)
else:
preview_object(current)
The next thing to do was implement an erase function. I added it as another entry into the object_to_place
enumeration. When a user clicked (or, now, dragged) the mouse, it would remove the tile at the mouse’s position. This sounds pretty simple, and it is. Before I wrote this article, though, it was anything but. For some reason, when we made this game we made the tile surface and tile edges separate tiles. This meant that whenever we added or removed a tile from the level, we’d have to manually edit the edges to be correct, based on the neighboring tiles. During the course of writing the introduction to this, I thought “what if I just add the edges to the tiles themselves? that way it would be handled automatically by the engine”. And it just workedβ’.
Anyway, my blood sweat and tears implementing the edges was all for naught, but I got the erase tool to work! And I had another demo!
Conclusion π
And that’s it for part 2! I’ll write a third (and hopefully final) part to this which will cover the wires and objects. Also, stick around for the post where I’ll talk about wiring up my house. But that’s all from me for today, see you next time!