admoore.xyz

Creating a Level Editor for my Game 'Unweighted' - Part 3: Objects


In my last post I left off after creating the system for creating and erasing tiles. These posts aren’t in strict chronological order, though: I had actually started working on today’s topic before I finished the work for the last post. But I figured for writing these breakdowns specifically, it would make a bit more sense to group them by content, not necessarily by the order in which I implemented things. So let’s get right into how I handled objects for the level editor.

The Object Interface 🔗

Up until now, I had only been dealing with things that live in TileMapLayers. So adding a tile and removing a tile already had a pre-defined interface as long as I kew the grid coordinates, which I always had because of how I wrote my input handler. Objects, however, are not tiles, so there had to be some special code for handling them. By this point I had committed to the notion of previewing, so the first thing to do was make a preview of the selected object follow the cursor.

Before I could do that, though, I had to make a common interface for the level objects to make things easy for future me. I’ll go through the file section by section.

class_name LevelObject
extends Node2D

enum {
	LEVEL_END,
	BUTTON,
	TOGGLE,
	GATE
}

@export_range(1, 6) var minimum_weight := 1
@export_range(1, 6) var maximum_weight := 6

After the boilerplate class name, the first thing is an enumeration that contains values for every possible object. There are also some exported variables for controlling the weight, for convenience.

The first function just returns the enumeration corresponding to the class. So the LevelEnd class overrides this to return LEVEL_END.

func get_object_type() -> int:
	push_error("Function not implemented.")
	return -1

The second function deals with the graphical position of the object. Each object has a different origin, depending on its size. Theoretically this is something we could have made fixed for all of them, but I didn’t want to refactor more than I had to.

func get_position_offset() -> Vector2:
	push_error("Function not implemented.")
	return Vector2.ZERO

The third function gets the position of the object within the level tile map. This is important when saving the level, as the position offset of some objects means if you use the generic Node2D.position attribute, you’d end up on the wrong tile.

func get_grid_position(tile_map: TileMapLayer) -> Vector2i:
	var tile_center_position := position - get_position_offset()
	return tile_map.local_to_map(tile_center_position)

Lastly, one more convenience function for drawing the weight indicator.

func update_weight_display() -> void:
	push_error("Function not implemented")

Handling Objects in the Editor 🔗

Because you can only ever select one object type at a time, I only needed to allocate one object at a time. So I created a class member of the level editor called current_object. So when a button for an object is pressed, I create an instance of the selected object and add it to the tree.

func button_toggled(toggled_on: bool, idx: int) -> void:
	ground_preview_tile_map.clear()
	free_current_object()

	if toggled_on:
		object_to_place = idx
		allocate_object()
        ...

## Allocate the object pointed to by object_to_place and store it in current_object.
func allocate_object() -> void:
	if object_to_place == FINISH_PAD:
		current_object = preload("res://src/objects/level_end.tscn").instantiate() as LevelEnd
	elif object_to_place == BUTTON:
		current_object = preload("res://src/objects/level_button.tscn").instantiate() as LevelButton
	elif object_to_place == TOGGLE:
		current_object = preload("res://src/objects/toggle.tscn").instantiate() as Toggle
	elif object_to_place == GATE:
		current_object = preload("res://src/objects/gate.tscn").instantiate() as Gate
		current_object.is_open = false
		current_object.tile_map = ground_tile_map

	if current_object != null:
		current_object.modulate = Color.TRANSPARENT
		level.objects.add_child(current_object)

You will notice that all objects are initialized as transparent. This is on purpose, because I didn’t want to allow placing objects outside of ground tiles. So preview_object() and place_object() have some special logic for dealing with this.

func preview_object(coords: Vector2i) -> void:
    ...
    elif object_to_place in [FINISH_PAD, BUTTON, TOGGLE, GATE]:
		if ground_tile_map.get_cell_source_id(coords) == 0 and not get_object_at_location(coords):
			current_object.modulate = OBJECT_PREVIEW_MODULATE
			current_object.position = ground_tile_map.map_to_local(coords) + current_object.get_position_offset()
		else:
			current_object.modulate = Color.TRANSPARENT
			current_object.position = Vector2(-20, -20)

func place_object(coords: Vector2i) -> void:
    ...
	elif object_to_place in [FINISH_PAD, BUTTON, TOGGLE, GATE]:
		if ground_tile_map.get_cell_source_id(coords) == 0 and get_object_at_location(coords) == current_object:
			current_object.position = ground_tile_map.map_to_local(coords) + current_object.get_position_offset()
			current_object.modulate = Color.WHITE

			# Allow holding shift to place multiple objects of the same type.
			if Input.is_key_pressed(KEY_SHIFT):
				allocate_object()
			else:
				current_object = null
				tile_button_group.get_pressed_button().button_pressed = false
			level.sort_objects()
			# Replace the ground under gate tiles in the editor.
			if object_to_place == GATE:
				level.place_tile(coords)

The behavior when pressing shift was a late addition. Early on I decided that placing an object would de-select the tool. The reasoning for this was that usually you’re only placing one object of a given type at a time, especially for things like level end pads, which you can’t have multiple of anyway. I figured that allowing bulk placement would be helpful though, so I added the shift feature.

In much the same way as tiles, the erase function will first show a preview by making it partially transparent. Then when the mouse button is pressed, the actual object is deleted. One nuance here I had to take care of was the fact that now multiple things (i.e. a tile and an object) could be on the same square at the same time. So I needed to add some logic to ensure that erasing would only affect the object first.

Editing Weight Requirements 🔗

The last thing I needed to do to finish off object handling was to add some way to edit their required weight. After all, that’s where most of the puzzle-ness comes from in this puzzle game. Of all the features of this level editor, this submenu was one of the ones I was most proud of. The submenu looks like this:

Each one of those numbers is a button inside of a ButtonGroup, making them act like radio buttons. The panel background is fixed, but the arrow is its own TextureRect. This means it can move around, and move around it does. The reason for this is I didn’t want this menu to ever go off of the screen. So I knew I needed to implement some sort of logic to snap it to be on-screen. I created a function to snap the menu to within a provided bounding box. This one is quite a doozy, so I’ll break it down.

The first thing we do is a sanity check to ensure the tooltip will even fit within the user-provided bounding box. Then we determine if this tooltip will have to be inverted or not. This alone is enough to ensure that the menu will never go outside of the game window in the y direction.

## Set the bounding box of this tooltip. The box is given in local coordinates where (0, 0)
## indicates the position of the object spawning the tooltip.
func set_bounding_box(bounding_box: Rect2) -> void:
	if bounding_box.size.x < panel.size.x:
		push_error("Bounding box too small to fit tooltip.")
		return

	var tooltip_height := ARROW_HEIGHT + panel.size.y

	var upside_down : bool
	if bounding_box.has_point(tooltip_height * Vector2.DOWN):
		upside_down = false
	elif bounding_box.has_point(tooltip_height * Vector2.UP):
		upside_down = true
	else:
		push_error("Bounding box too small to fit tooltip.")
		return

Next we set the direction of the arrow, and move the panel to match.

	# Set the arrow direction.
	if upside_down:
		arrow.scale.y = -1
		panel.position.y = -tooltip_height
	else:
		arrow.scale.y = 1
		panel.position.y = ARROW_HEIGHT

Now we get to the real meat: we calculate the minimum and maximum x position of the tooltip. By default, the panel should have a position of -panel.size.x / 2.0, which would center the panel on the arrow horizontally. So those are the bounds for the ranges. The MARGIN is there to give some padding at the edge of the screen.

	var bounding_box_min_x := bounding_box.position.x
	var bounding_box_max_x := bounding_box.position.x + bounding_box.size.x

	var panel_min_x_pos = max(bounding_box_min_x + MARGIN, -panel.size.x / 2.0)
	var panel_max_x_pos = min(bounding_box_max_x - panel.size.x - MARGIN, -panel.size.x / 2.0)

Finally, we try to center the box, but clamp that value to the range we calculated above.

	panel.position.x = clampf(-panel.size.x / 2.0, panel_min_x_pos, panel_max_x_pos)

And that’s it! Once I had it implemented I recorded another quick demo for my team. Notice the placement of each submenu and how it’s always visible.

Conclusion 🔗

After writing this, I think I like the shorter entries posted more often. So to that end, next week I’ll try to get out a post covering the wire system, and the week after that I’ll go over the final touches. Stay tuned for those, and I’ll see you next time.