Home Creating 2.5D Clickable Map with Borders in Godot
Post
Cancel
Preview Image

Creating 2.5D Clickable Map with Borders in Godot

Collision Detection Using a Color Map

After some searching, I saw an interesting way to create a clickable map that seems efficient and composed of two maps. The first is visible without any collision detection (our wireframe), and the second is in front with a unique color for each country, and it has a collider. Then, we check the pixel color from the mouse’s position; if it’s pink, we know it’s Australia. We can scale it down to countries, then regions, etc. Here’s the example from StackExchange:

Countries map Map of all countries

Color coded map Each region will have a unique color


Understanding the Workflow

Godot workflow Godot workflow

I created the following workflow: TextureMap and CountriesWireframe are simple 3DSprites. HiddenColorData is a QuadMesh I manually scaled to the size of the TextureMap, and it has a collider that will be used for get_pixel().

Code Snippets

I encountered an excellent post on Reddit which already implemented what I wanted. I mainly used the asset the author highlighted for camera calculations with ray casting: we perform a ray cast from the camera to the point on the invisible (color) map, then it gets the pixel and we decide what this pixel means (which country was selected).

1
2
3
4
5
6
7
8
9
10
onready var _hidden_quad_color_data = $HiddenColorData

func _ready():
	# Load the color-coded map to the QuadMesh (HiddenQuadColorData) and lock it so we can pick its colors.
	var temp_texture = ImageTexture.new()
	var temp_image = Image.new()
	temp_image.load("res://Assets/Maps/Images/World Map/world_map_colors.png")
	temp_texture.create_from_image(temp_image)
	_hidden_color_map_ = temp_texture.get_data()
	_hidden_color_map_.lock()

Here are the maps, currently only Brazil has a color (green):

Visible map A visible map which will be shown to the player

Hidden map A hidden map with unique colors for each country. Only Brazil for now

Camera movement:

1
2
3
4
5
6
7
8
9
10
func _physics_process(delta):
	# Define -1 to +1 direction changes
	cam_direction.x = (-int(Input.is_action_pressed("map_left")) + int(Input.is_action_pressed("map_right")))
	cam_direction.y = (-int(Input.is_action_pressed("map_down")) + int(Input.is_action_pressed("map_up")))
	cam_direction.z = (-int(Input.is_action_pressed("map_zoom_in")) + int(Input.is_action_pressed("map_zoom_out")))
	cam_direction = cam_direction.normalized()
	
	# Interpolate velocity and modify cam position
	cam_velocity = cam_velocity.linear_interpolate(cam_direction * cam_speed, cam_acceleration * delta)
	_camera.transform.origin += cam_velocity

Handling hover and click event:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
func _unhandled_input(event):
	# Handling both hover and click
	# InputEventMouseButton & InputEventMouseMotion different logic!
	if event is InputEventMouseButton or event is InputEventMouseMotion:
		if _hidden_color_map_ == null: return false
		# Get mesh size to detect edges and make conversions
		# This code only support PlaneMesh and QuadMesh
		var quad_mesh_size = _hidden_quad_color_data.mesh.size
		
		# Find mouse position in Area
		var from = _camera.project_ray_origin(event.global_position)
		var dist = 100
		var to = from + _camera.project_ray_normal(event.global_position) * dist
		var result = get_world().direct_space_state.intersect_ray(from, to, [], _hidden_quad_color_data.get_child(0).collision_layer,false,true)
		var mouse_pos3D = null
		if result.size() > 0: mouse_pos3D = result.position
		# Check if the mouse is outside of bounds, use last position to avoid errors
		# NOTE: mouse_exited signal was unreliable in this situation
		var is_mouse_inside = (mouse_pos3D != null)
		if is_mouse_inside:
			# Convert click_pos from world coordinate space to a coordinate space relative to the Area node.
			# NOTE: affine_inverse accounts for the Area node's scale, rotation, and translation in the scene!
			mouse_pos3D = _hidden_quad_color_data.get_child(0).global_transform.affine_inverse() * mouse_pos3D
			_last_mouse_pos3D = mouse_pos3D
		else:
			mouse_pos3D = _last_mouse_pos3D
			if mouse_pos3D == null:
				mouse_pos3D = Vector3.ZERO

		# convert the relative event position from 3D to 2D
		var mouse_pos2D = Vector2(mouse_pos3D.x, -mouse_pos3D.y)
		# Right now the event position's range is the following: (-quad_size/2) -> (quad_size/2)
		# We need to convert it into the following range: 0 -> quad_size
		mouse_pos2D.x += quad_mesh_size.x / 2.0
		mouse_pos2D.y += quad_mesh_size.y / 2.0
		# Then we need to convert it into the following range: 0 -> 1
		mouse_pos2D.x = mouse_pos2D.x / (quad_mesh_size.x)
		mouse_pos2D.y = mouse_pos2D.y / (quad_mesh_size.y)
		# Finally, we convert the position to the following range: 0 -> _hidden_color_map_.size
		mouse_pos2D.x = mouse_pos2D.x * _hidden_color_map_.get_width()	
		mouse_pos2D.y = mouse_pos2D.y * _hidden_color_map_.get_height()
		
		# Detect country color code
		var px_color = _hidden_color_map_.get_pixelv(mouse_pos2D)
		
		# Decide what to do
		if event is InputEventMouseButton:
			mouse_button_country_handler(px_color)
		elif event is InputEventMouseMotion:
			mouse_motion_country_handler(px_color)

By the time of writing this post, I already created a shader to select countries (I’ll write about it next). The code below refers to that shader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
_current_selected_country = null  # Should be at the top of your script

# If the country clicked, create a border. 
func mouse_button_country_handler(country_color_code):
  if country_color_code == Color(0,1,0) \  # Green color
	and _current_selected_country != "Brazil":
		_brazil.material_override.set_shader_param("IsOutlined", true)
		# Store locally the current selected country 
		_current_selected_country = "Brazil"
	elif country_color_code != Color(0,1,0):  # Green color
		# Reset selection effect
		_current_selected_country = null
		_brazil.material_override.set_shader_param("IsOutlined", false)
		_brazil.material_override.set_shader_param("ColorAlbedoUniform", Color.black)


# If the country hovered, highlight it gently. 
func mouse_motion_country_handler(country_color_code):
	if country_color_code == Color(0,1,0) \  # Green color
	and _current_selected_country != "Brazil":
		_brazil.material_override.set_shader_param("ColorAlbedoUniform", Color.red)
	elif _current_selected_country != "Brazil":
		# Reset hover effect
		_brazil.material_override.set_shader_param("ColorAlbedoUniform", Color.black)		
		

Any code related to the camera is a combination of the asset mentioned above and the code made by the author of the reddit post - link to his repo.

If you get a side-effect of non-consistent layers’ locations when moving the camera, make sure you only translate the parent node - any child’s translations should set to 0.

All trademarks, registered trademarks, games' footage, and referenced code/materials are the property of their respective owners.

Finding Map Assets for a Grand Strategy Game

Setting Map Shaders with GDScript & Visual Graphs