Avoiding obstacles with TileMapLayer

Godot Version

4.3

Question

Using TileMapLayer, how can I make NavigationAgent2D avoid only the collision pollygons of my tiles in Godot 4.3?

Hello, I created an enemy and added NavigationAgent2D to make it follow my player, and enable Navigation for my background TileMapLayer (essentially grass).
I’m trying to make it avoid obstacles (such as trees and other objects), but my script does not detect the tiles I want it to avoid.

I have a script for my TileMapLayer to detect tiles to avoid.

extends TileMapLayer

@onready var decor_1: TileMapLayer = $"../Forest/Decor1"

func _use_tile_data_runtime_update(coords: Vector2i) -> bool:
	if coords in decor_1.get_used_cells_by_id(0):
		return true
	return false
	
func _tile_data_runtime_update(coords: Vector2i, tile_data: TileData) -> void:
	if coords in decor_1.get_used_cells_by_id(0):
		tile_data.set_navigation_polygon(0, null)

But as you can see, only one tile is ignored for each of my elements.
image

I created one big tile for each element I placed on the map, as you can see, but only the framed cell (like in the screenshot) is avoided.
image

However, I want it to avoid only the collision part of my element because there is y-sorting.
image

How to make the collision polygon avoided by the navigation ?

Thank you for your help.

You might substitute the if coords in decor_1.get_used_cells_by_id(0): with a for loop and add a custom offset for each cell
Alternatively, you could chech for collision for each cell with PhysicsDirectSpaceState2D.intersect_point() but it might have an impact on the performance

EDIT
With custom offset I mean the offset between the center tile and the one with collision shape
You should then check on runtime if a cell is occupied or not by looking at it

You should be able to do the first option with tilemap’s custom data layer

Or even pre-check all the tiles with collision with a custom tool or in as the project runs to avoid doing that in runtime

Hello.
I tried to apply your first solution but my implementation seems a bit approximative.

extends TileMapLayer

@onready var decor_1: TileMapLayer = $"../Forest/Decor1"
var custom_offset: Vector2i = Vector2i(0, 2)

func _use_tile_data_runtime_update(coords: Vector2i) -> bool:
	for cell in decor_1.get_used_cells_by_id(0):
		if coords == cell + custom_offset:
			return true
	return false
	
func _tile_data_runtime_update(coords: Vector2i, tile_data: TileData) -> void:
	for cell in decor_1.get_used_cells_by_id(0):
		if coords == cell + custom_offset:
			tile_data.set_navigation_polygon(0, null)

As you can see I moved the tiles with navigation disabled, and I could adapt it to each element but it’s not very optimal.

image

Thank you very much for your help.

I also tried to apply PhysicsDirectSpaceState2D.intersect_point() but I don’t really understand how to use it.

extends TileMapLayer

@onready var decor_1: TileMapLayer = $"../Forest/Decor1"

func _use_tile_data_runtime_update(coords: Vector2i) -> bool:
	var cell_position = decor_1.map_to_local(coords)
	var space_state = get_world_2d().direct_space_state
	var pointQuery = PhysicsPointQueryParameters2D.new()
	pointQuery.canvas_instance_id = 0
	pointQuery.collide_with_areas = false
	pointQuery.collide_with_bodies = true
	pointQuery.collision_mask = 4294967295
	pointQuery.exclude = []
	pointQuery.position = cell_position
	var result = space_state.intersect_point(pointQuery)
	return result.size() > 0
	
func _tile_data_runtime_update(coords: Vector2i, tile_data: TileData) -> void:
	var cell_position = decor_1.map_to_local(coords)
	var space_state = get_world_2d().direct_space_state
	var pointQuery = PhysicsPointQueryParameters2D.new()
	pointQuery.canvas_instance_id = 0
	pointQuery.collide_with_areas = false
	pointQuery.collide_with_bodies = true
	pointQuery.collision_mask = 4294967295
	pointQuery.exclude = []
	pointQuery.position = cell_position
	var result = space_state.intersect_point(pointQuery)
	if result.size() > 0:
		tile_data.set_navigation_polygon(0, null)

Then I get a weird result with one tile ignored.

Thanks for your help

Are you sure about the collision layer?
4294967295 seems pretty weird

I found this value in the docs as default value (tried without specifying any collision_mask and got same result)

Ok
You might try to create a debug scene and test intersect_point() on various subjects to see if you can make it to work
Right now I can’t use my computer so I can’t actually do it

Also do this space_state.intersect_point(pointQuery, 1) instead of this space_state.intersect_point(pointQuery) to save some performance, but maybe it’s better to do it after you made it work

1 Like

Ok thanks a lot for your time.

To clarify I made something like this for TileMapLayers

image

Also, I added navigation layer on “Background”

In the editor, whad does the physics layer-mask setup look like?

Also what does its transform look like?

Hello, on every TileMapLayers I got this same configuration

For the transform

Left image is Background (I moved it a bit)
Right image is every other TileMapLayers

Ok I got it to work!
Here’s the code:

extends TileMapLayer

@onready var decor_1: TileMapLayer = $"../Forest/Decor1"

func _use_tile_data_runtime_update(coords: Vector2i) -> bool:
	return intersect_point(map_to_local(coords))
	
func _tile_data_runtime_update(coords: Vector2i, tile_data: TileData) -> void:
	if intersect_point(map_to_local(coords)):
		tile_data.set_navigation_polygon(0, null)

func intersect_point(point: Vector2) -> bool:
	var space_state := get_world_2d().direct_space_state
	var pointQuery := PhysicsPointQueryParameters2D.new()
	pointQuery.collide_with_areas = false
	pointQuery.collision_mask = 2#<-HERE
	pointQuery.position = point
	var result = space_state.intersect_point(pointQuery, 1)
	return not result.is_empty()

func _ready() -> void:
	await get_tree().process_frame
	notify_runtime_tile_data_update.call_deferred()

The thing is that the collision are not loaded yet when the TileMap gets updated so doing it manually after one frame worked! (for me at least)
To avoid scanning other collisions, I made it check only for collision mask 2 so to make this script work you have to add the decor_1 layer to collision mask 2
If you wish to use a different layer, you can get the int value for that mask using the following formula: 2**(desired_mask-1)
and use that value instead of the 2 where I signed it
I wish this will work for you too!

2 Likes

Yes it works !!
Thank you so much I had hard time on this, have a nice day.

image

IMPORTANT :
I had to delete my modifications (position, scale) in the “Transform” category so that the tiles would be removed in the right place.

Oh right
You can avoid this using to_global(map_to_local(coords))) instead of map_to_local(coords))

1 Like

Yes it worked.
Thanks again !

You’re welcome!

1 Like