How to deal with signals not emitting in desired order

Godot Version

4.3.dev6

Question

I’m working on a system that sets the limits of the camera based on the position of the player.

I have implemented this system using the nodes Panel and Area2D: I set the position and the size of the Panel node in the 2d viewport and a script then creates the CollisionShape2D for the Area2D based on the rectangle of the Panel node.
To handle overlapping Panels, each Panel has a priority. The Panel with the highest priority sets the limit for the camera.

I keep track of the CameraLimiters that contain the player using the
variable contains_player. This bool gets set in the _on_area_body_entered and _on_area_body_exited methods. Here’s the problem: I can’t rely on the order of the signals body_entered and body_exited. For example, when the player exits one Area2D and enters another, the body_entered signal of one Area2D fires before the body_exited of the other Area2D. Thus, the script wrongly concludes that there are two Area2Ds where contains_player is true.

How can I fix this behaviour? I want the _on_area_body_exited function of all CameraLimiter nodes to execute before the _on_area_body_entered of any CameraLimter.

class_name CameraLimiter 
extends Panel

@export var priority := 0

var contains_player: bool

func _ready() -> void:
	area.body_entered.connect(_on_area_body_entered)
	area.body_exited.connect(_on_area_body_exited)
	
	var rectangle_shape := RectangleShape2D.new()
	rectangle_shape.size = size
	collision_shape.shape = rectangle_shape
	collision_shape.position = size / 2

func _on_area_body_entered(body: Node2D) -> void:
	contains_player = true
	
	for camera_limiter: CameraLimiter in get_tree().get_nodes_in_group("camera_limiter"):
		if not camera_limiter.contains_player:
			continue
		
		if camera_limiter.priority > max_priority:
			max_priority = camera_limiter.priority
			camera_limiter_with_max_priority = camera_limiter

func _on_area_body_exited(body: Node2D) -> void:
	contains_player = false

In this case, when a player enters a CameraLimiter area have all other CameraLimiter’s areas stop monitoring.

func set_monitoring(state: bool) -> void:
	area.monitoring = state

func _on_area_body_entered(body: Node2D) -> void:
	for camera_limiter: CameraLimiter in get_tree().get_nodes_in_group("camera_limiter"):
		camera_limiter.set_monitoring(self == camera_limiter)

func _on_area_body_exited(body: Node2D) -> void:
	get_tree().call_group("camera_limiter", "set_monitoring", true)

When the body exits the area, the current area will emit body exited. When monitoring is enabled the new area will will then emit body entered.

You may choose to modify the area’s collision_mask instead of monitoring.

Thanks a lot for your reply but this solution does not work for me. It doesn’t pdate the camera bounds as I would like. The smaller rect doesn’t get enabled when I spawn the player in it…

I guess I have to call the update_camera_limits function every frame for now…

func _process(delta: float) -> void:
	update_camera_limits()


func update_camera_limits() -> void:
	var max_priority := -INF
	var camera_limiter_with_max_priority: CameraLimiter
	var overlapping_areas_count := 0
	
	for camera_limiter: CameraLimiter in get_tree().get_nodes_in_group("camera_limiter"):
		var overlapping_bodies := camera_limiter.area.get_overlapping_bodies()
		
		if overlapping_bodies.is_empty():
			continue
		
		if camera_limiter.priority > max_priority:
			max_priority = camera_limiter.priority
			camera_limiter_with_max_priority = camera_limiter
	
	var game_camera := get_tree().get_first_node_in_group("game_camera") as GameCamera
	
	if game_camera == null:
		return
	
	game_camera.update_limits(camera_limiter_with_max_priority.get_rect2())

I thought about this some more. The image you posted is good info. I see now that you have areas within areas.

Here’s another idea: use a priority queue that updates when a new area has entered.

This is similar to what you originally posted. But control is inverted. Instead of having the camera limiter itself choose who is in control, we have an external object that keeps a sorted queue of all the areas that the body is currently inside.

The “current” area will be the area with the highest priority and will remain current until an area with a higher priority has been entered or the current area has been exited.

Areas with the same priority are sorted with the earliest added towards the front.

Try adapting this to your CameraLimiter use case.

signal current_changed(area)

var queue = []

func get_current():
	return queue[0] if queue.size() > 0 else null

func _ready() -> void:
	for area: Area2D in get_tree().get_nodes_in_group('area'):
		area.body_entered.connect(update_queue.bind(true, area))
		area.body_exited.connect(update_queue.bind(false, area))

func sort_queue(a: Area2D, b: Area2D) -> bool:
	if a.priority == b.priority:
		return false # oldest first, stable for sample size.
	return a.priority > b.priority

func update_queue(body: Node2D, entered: bool, area: Area2D) -> void:
	var previous = get_current()

	if entered:
		queue.append(area)
		queue.sort_custom(sort_queue)
	else: # exited
		queue.erase(area)
	
	if previous != get_current():
		current_changed.emit(get_current())

Notes

Array.sorts algorithm as described in the docs is not stable. You will most likely need to customize the sorting to get the desired behavior. The sort_queue comparator has stable results, but if you change return false to return true i.e. sort the same priority by most recent first, is not stable.