Skip to content

Instantly share code, notes, and snippets.

@EIREXE
Last active April 1, 2022 09:35
Show Gist options
  • Save EIREXE/0a895cc5838b6fa5640c4cc758913fa6 to your computer and use it in GitHub Desktop.
Save EIREXE/0a895cc5838b6fa5640c4cc758913fa6 to your computer and use it in GitHub Desktop.
Automatic minimap generation in godot

Building an automated map/minimap system in Godot

Why?

Our game, ERO-ONE needed a minimap system, it being an open world game made this a necessity, otherwise the player wouldn't really know where he's going.

Of course making a proper minimap is hard, we told ourselves that our game didn't really need it because it's not as big as the open world powerhouse that is Grand Theft Auto, but the real reasons are...

  • We are lazy (we are Spanish)
  • We have no money for artists
  • We have a game that will have a changing world during it's development

Roads

Road nodes

One of the things minimaps have to do is render the road the player is on, to do this we created nodes that will act as road waypoints, they look like this:

These nodes inherit Position3D, so it's possible to click on them, and have a always front facing Sprite3D on them, for easy identification, a road can be disconnected, which means it doesn't have a parent road and it only has children.

The code for this node is very simple, and mostly code to draw the road connections in the editor, we use notifications for updating the line drawings each time the node is moved, here's some example code:

# (MinimapRoad.gd)
func _ready():
	if disconnected:
		add_to_group("map_roads")

	if Engine.editor_hint:
		set_notify_transform(true)

	draw_debug_line()
		
func _notification(what):
	if Engine.editor_hint:
		if what == Spatial.NOTIFICATION_TRANSFORM_CHANGED:
			draw_debug_line()
		
func draw_debug_line():
	if Engine.editor_hint:
		if not disconnected:
			if not imm:
				imm = ImmediateGeometry.new()
				add_child(imm)
			imm.clear()
			imm.begin(Mesh.PRIMITIVE_LINES)
			if get_parent():
				imm.add_vertex(to_local(get_parent().global_transform.origin))
				imm.set_color(Color(0.0, 0.0, 0.0))
				imm.add_vertex(Vector3(0.0, 0.0, 0.0))
				imm.end()
		else:
			if imm:
				imm.clear()

As you can see, we use an ImmediateGeometry node to draw connections to the parent node if the node is disconnected.

Because roads don't change we don't have a central data storage for them, they simply are part of the group map_roads and that's how other nodes can find them.

Road renderer

The road renderer is where most of the work went in, the road renderer is a control, but it's inside a viewport, so that it's not only easier to use, but can be used by various nodes at the same time, and you get the plus of being able to use transparency on it.

One of the things you can do is set the map drawing origin, this is the central point from which the minimap will be drawn, why do this instead of simply setting rect_position? because if our renderer node is moved too much out of camera it stops being drawn, I am not sure if this can be disabled.

The origin is stored as a Vector3.

Another important thing is being able to set the map scale and set the center of where the map is drawn from, and to do so we have a method named global2minimap which takes care of converting global 3d coordinates to minimap coordinates:

# (RoadRenderer.gd)
func global2minimap(position):
	var map_position = Vector2(position.x, position.z)
	
	map_position *= scale
	map_position += get_parent().size / 2
	
	return map_position - Vector2(origin.x, origin.z) * scale

As you can see it's quite a simple thing, first we have to convert the given position so that the 3D world's (0.0, 0.0, 0.0) is the center of our renderer Viewport, we first convert the 3D position into a 2D position by taking the X and the Z into a Vector2, then we scale it and add half the size of the viewport to center the 3D coordinate system to the middle of the screen.

After that we subtract our converted and scaled origin, so origin becomes the new center of the screen.

To actually draw the roads, we use a recursive function named draw_roads, that's called from _draw():

# (RoadRenderer.gd)
func _draw():
	var road_roots = get_tree().get_nodes_in_group("minimap_roads")
	for root_node in road_roots:
		draw_roads(root_node)

func draw_roads(node):
	var origin = get_transformed_origin()
	var from = global2minimap(node.global_transform.origin)
	for child in node.get_children():
		var to = global2minimap(child.global_transform.origin)
		
		var width = 5
		if width_varies_with_scale:
			width = 2.5 * scale
		draw_line(from, to, ROAD_COLOR, width, true)
		if child.get_child_count() > 1:
			draw_circle(to, width/2, ROAD_COLOR)
		draw_roads(child)

The only slighly janky thing in this method is the drawing of circles, you may wonder what they are for, and they are for ensuring connections between two roads are round and look good:

Markers

Markers are used for marking relevant points in the world, such as where a certain character is, where the player is or where a mission ends...

The cool thing about them is that they automatically update their position in the map, however the road renderer does not take care of rendering them, because both the big pause menu map and the minimap do it in different ways (the minimap has the added complexity that it rotates).

Markers can be of types, for example, the current implemented types are the player indicator and a generic pink dot, it's important to differenciate marker type and marker name, a marker can be of type "Character" but be named "Peter"

# (Marker.gd)

enum MarkerType {
	PLAYER,
	DOT
}

var marker_type_data = {
	MarkerType.PLAYER: {
		"name": "Player",
		"icon": preload("res://System/Textures/ui/Icons/nav_player.png")
	},
	MarkerType.DOT: {
		"name": "Point of interest",
		"icon": preload("res://System/Textures/ui/Icons/nav_dot.png")
	}
}

Markers can also display rotation, such as the player icon.

Finally markers can be set to be important, this means that they will always be visible in the map even if you pan away from them, by appearing in the corner of the map.

Markers report their existence to the map manager as soon as they are created, and they report their demise when they are deleted.

# (Marker.gd)

signal marker_position_changed

func _ready():
	var map_manager = EROGameModeManager.game_mode.map_manager
	if map_manager:
		map_manager.add_marker(self)
	set_notify_transform(true)
		
func _notification(what):
	if not Engine.editor_hint:
		if what == NOTIFICATION_PREDELETE:
			if EROGameModeManager.game_mode:
				var map_manager = EROGameModeManager.game_mode.map_manager
				if map_manager:
					map_manager.delete_marker(self)
		if what == NOTIFICATION_TRANSFORM_CHANGED:
			emit_signal("marker_position_changed")

Marker also has a few simple methods to make placement easier called set_position_centered and set_position_centered_global:

# (Marker.gd)

func set_position_centered(position):
	rect_position = position - rect_size / 2
	
func set_position_centered_global(position):
	rect_global_position = position - rect_size / 2

The map manager

The map manager is a global singleton that takes care of broadcasting signals sent by the markers to to whoever wants to listen to them, because it's so simple the full code is:

# (MapManager.gd)

extends Node

signal marker_position_changed
signal new_marker

func add_marker(marker):
	
	emit_signal("new_marker", marker)
	
func delete_marker(marker):
	emit_signal("marker_deleted", marker)

The pause menu map

The way the pause menu map is setup is simply a TextureRect that shows the renderer viewport from a ViewportTexture, it has a child control that will serve as a container for marker icons.

Everytime the map is shown or it's size changes the renderer's Viewport size is updated:

# (PauseMap.gd)

func update_viewport_size():
	get_renderer_viewport().size = rect_size

Rendering markers

Everytime a marker is added to the world it also gets added to the pause menu map:

# (PauseMap.gd)

func add_new_marker(marker):
	if not marker_categories.has(marker.type):
		marker_categories[marker.type] = []
	var map_icon = MinimapIcon.new()
	map_icon.sprite_texture = marker.get_marker_icon()
	
	var marker_data = {
		"map_icon": map_icon,
		"world_marker": marker
	}
	
	marker_categories[marker.type].append(marker_data)
	
	$MapIcons.add_child(map_icon)
	
	marker.connect("marker_position_changed", self, "update_marker_transform", [marker_data])
	
	update_marker_transform(marker_data)

In order to place the marker in it's container we use update_marker_transform, this takes care of everything:

# (PauseMap.gd)

func global2map(origin):
	return get_renderer().global2map(origin)
	
func update_marker_transform(marker_data):
	var map_icon = marker_data["map_icon"]
	var marker = marker_data["world_marker"]
	
	var position = global2map(marker.global_transform.origin)
	if marker.important:
		position.x = clamp(position.x,  map_icon.rect_size.x / 4, rect_size.x - map_icon.rect_size.x / 4)
		position.y = clamp(position.y,  map_icon.rect_size.y / 4, rect_size.y - map_icon.rect_size.y / 4)
	
	map_icon.set_position_centered(position)
	
	if marker.display_rotation:
		var rotation = marker.global_transform.basis.get_euler()
		map_icon.sprite_rotation = rad2deg(-rotation.y)

We just use the same method from the renderer (global2map) to place the markers on their position.

As you can see, whenever the marker is marked as important we clamp the icon position to the size of the container and we use the size of the icon to make sure it never goes partially out of our view, to illustrate this, here's an icon that is following an offscreen marker:

Panning and zooming

Panning is done by using relative mouse movement when holding a key (in this case the middle mouse button), and zooming is done using two actions named zoom_in and zoom_out (using the method get_action_strength which will be available in 3.1).

# (PauseMap.gd)

func set_map_origin(origin):
	get_renderer().set_origin(origin)
	update_all_marker_transforms()
	
func _input(event):
	var zoom = Input.get_action_strength("zoom_in") - Input.get_action_strength("zoom_out")
	map_scale_target = map_scale_target + zoom*(EROSettings.zoom_speed*15.0*get_process_delta_time())
	if event is InputEventMouseMotion and Input.is_action_pressed("map_drag"):
		var origin = get_renderer().origin
		var origin_diff = event.relative / get_renderer().scale
		origin -= Vector3(origin_diff.x, 0, origin_diff.y)
		set_map_origin(origin)

To make zooming smoother, we have decided to interpolate it, this is obviously done in _process:

# (PauseMap.gd)

func _process(delta): 
	if visible:
		if map_scale_target != get_renderer().scale:
			get_renderer().scale = lerp(get_renderer().scale, map_scale_target, 10*delta)
			update_all_marker_transforms()

The minimap

The minimap shares mostly the same code as the normal map, however the setup is different, we do use a TextureRect too, but this time it's inside a Control and the TextureRect is twice as big as the Control, this is to allow the TextureRect to freely rotate without issues, to achieve this we use the clip_content property of the Control.

First in _process we use our camera rotation to rotate the map around:

# (Minimap.gd)

func _process(delta):
	if visible:
		var player = EROGameModeManager.game_mode.player
		if player:
			set_map_origin(player.global_transform.origin)
			var camera_gimball = player.character.camera_gimball_base
			var rotation = camera_gimball.global_transform.basis.orthonormalized().get_euler() 
			rect_pivot_offset = rect_size / 2
			rect_rotation = rad2deg(-(rotation.y)) - 180.0
			

As you can see simply taking our camera rotation and converting it to degrees works well for setting rect_rotation.

The problem comes when we have to place the icons, the icons (unless they have display rotation on) should rotate independently from the map, so they can't really be children of the rotating TextureRect, which is why they are in a separate node that is a child of the minimap container.

So now we have to do some Transform2D shenanigans to get it to work:

# (Minimap.gd)

func update_marker_transform(marker_data):
	var map_icon = marker_data["map_icon"]
	var marker = marker_data["world_marker"]

	var position = global2map(marker.global_transform.origin)

	var position_transform = Transform2D()
	
	position_transform.origin = position
	position_transform = get_global_transform() * position_transform
	map_icon.set_position_centered_global(position_transform.origin)
	
	var map_icons_container = get_node("../MapIcons")
	
	if marker.important:
		map_icon.rect_position.x = clamp(map_icon.rect_position.x, -map_icon.rect_size.x * 0.25, map_icons_container.rect_size.x - map_icon.rect_size.x * 0.75)
		map_icon.rect_position.y = clamp(map_icon.rect_position.y, -map_icon.rect_size.y * 0.25, map_icons_container.rect_size.y - map_icon.rect_size.y * 0.75)

	if marker.display_rotation:
		var rotation = marker.global_transform.basis.get_euler()
		map_icon.sprite_rotation = rad2deg(-(rotation.y)) + rect_rotation
		

To achieve this we have changed how it works a bit, first we create a Transform and set the origin to the map position using global2map, we then multiply it by the TextureRect's transform so that we know the position it should be if it was rotated together with the TextureRect, then we use the multiplied transform's origin to place our marker's icon using set_position_center_global (as we used the global transform before).

We then simply clamp it's rect position and finally we set the rotation in the same way we did before, except this time we add the TextureRect's rect_rotation to it so it's rotated properly.

Final thoughts

This is of course, not 1:1 to the code used in the game, as there are a few more cosmetic additions (such as labels when you hover over minimap icons), but it should be a good inspiration for anyone working on a similar system.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment