Godot 4: Detect body collision before adding Node to scene in a 2D game (part 2).
Introduction
In my previous blog post, I explained how we can detect two overlapping objects before making it visible in the scene. I achieved this with an Area2D
and a CollisionShape2D
. However, this was not the ideal solution I was aiming for, as it still required adding an invisible object to the scene and working with signals and await
to wait for the first frame to be rendered in order to detect a collision.
After some Googling, I found a different (and in my opinion, a better) solution by making use of the PhysicsDirectSpaceState2D
class and performing a query with the PhysicsShapeQueryParameters2D
class.
The PhysicsDirectSpace2D class has the following description:
Provides direct access to a physics space in the PhysicsServer2D. It's used mainly to do queries against objects and areas residing in a given space.
So we can do queries on a 2D space. That's interesting. So how do we actually do queries? The PhysicsDirectSpace2D class has a function intersect_shape
(docs), which requires as first parameters an instance of the class PhysicsShapeQueryParameters2D :
By changing various properties of this object, such as the shape, you can configure the parameters for PhysicsDirectSpaceState2D.intersect_shape.
Ok, let's see how this works.
The good solution 2: Making queries on the PhysicsDirectSpace2D
Let's clean up the project first by removing unnecessary code and go back to a state where we programmatically add a planet again. When we run the game, we will once again see two planets, but without the SpawnBoundary
nodes from the previous solution:
Let's attempt to use the PhysicsDirectSpaceState2D
class in the Game scene script:
- First get the Space state by using
var space_state = get_world_2d().direct_space_state
. This returns an instance ofPhysicsDirectSpaceState2D
:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# Programmatically add planet
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = Vector2(600, 300)
add_child(second_planet)
# Detect other objects in Space
# 1. Get the PhysicsDirectSpaceState2D
var space_state = get_world_2d().direct_space_state
- Now, we want to perform a query on the space state using the
intersect_shape
function. Remember that the first parameter is an instance of thePhysicsShapeQueryParameters2D
class. So, let's create that first:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# Programmatically add planet
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = Vector2(600, 300)
add_child(second_planet)
# Detect other objects in Space
# 1. Get the PhysicsDirectSpaceState2D
var space_state = get_world_2d().direct_space_state
# 2. Create the PhysicsShapeQueryParameters2D instance
var shape_query_params = PhysicsShapeQueryParameters2D.new()
- Now, we want to define two things: the shape [docs] we want to use to query the space state—in our case, a circle—and also the origin of the shape, indicating where we want to place the circle to determine what is inside it. We can use the transform [docs] property for this. The transform property is a
Transform2D
object, which contains theorigin
property [docs]. Let's start by creating the shape. I'm using a radius of 200 pixels::
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# Programmatically add planet
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = Vector2(600, 300)
add_child(second_planet)
# Detect other objects in Space
# 1. Get the PhysicsDirectSpaceState2D
var space_state = get_world_2d().direct_space_state
# 2. Create the PhysicsShapeQueryParameters2D instance
var shape_query_params = PhysicsShapeQueryParameters2D.new()
# 3. Create the Circle shape
var shape = CircleShape2D.new()
shape.radius = 200
- Now we are gonna set the properties for the
PhysicsShapeQueryParameters2D
instance:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# Programmatically add planet
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = Vector2(600, 300)
add_child(second_planet)
# Detect other objects in Space
# 1. Get the PhysicsDirectSpaceState2D
var space_state = get_world_2d().direct_space_state
# 2. Create the PhysicsShapeQueryParameters2D instance
var shape_query_params = PhysicsShapeQueryParameters2D.new()
# 3. Create the Circle shape
var shape = CircleShape2D.new()
shape.radius = 200
# 4. Set the shape and origin
shape_query_params.shape = shape
shape_query_params.transform.origin = Vector2(500, 300)
- Let's now execute the query on the space state and print the results:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# Programmatically add planet
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = Vector2(600, 300)
add_child(second_planet)
# Detect other objects in Space
# 1. Get the PhysicsDirectSpaceState2D
var space_state = get_world_2d().direct_space_state
# 2. Create the PhysicsShapeQueryParameters2D instance
var shape_query_params = PhysicsShapeQueryParameters2D.new()
# 3. Create the Circle shape
var shape = CircleShape2D.new()
shape.radius = 200
# 4. Set the shape and origin
shape_query_params.shape = shape
shape_query_params.transform.origin = Vector2(500, 300)
# 5. Perform the query
var results = space_state.intersect_shape(shape_query_params)
print(results)
- For debugging purposes, let's draw an
Area2D
node with aCollisionShape2D
child node on top of the CircleShape2D:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# Programmatically add planet
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = Vector2(600, 300)
add_child(second_planet)
# Detect other objects in Space
# 1. Get the PhysicsDirectSpaceState2D
var space_state = get_world_2d().direct_space_state
# 2. Create the PhysicsShapeQueryParameters2D instance
var shape_query_params = PhysicsShapeQueryParameters2D.new()
# 3. Create the Circle shape
var shape = CircleShape2D.new()
shape.radius = 200
# 4. Set the shape and origin
shape_query_params.shape = shape
shape_query_params.transform.origin = Vector2(500, 300)
# 5. Perform the query
var results = space_state.intersect_shape(shape_query_params)
print(results)
# 6. Debug
var area_2d = Area2D.new()
area_2d.position = shape_query_params.transform.origin
var collision_shape_2d = CollisionShape2D.new()
var collision_shape = CircleShape2D.new()
collision_shape.radius = shape.radius
collision_shape_2d.shape = collision_shape
area_2d.add_child(collision_shape_2d)
add_child(area_2d)
- Be sure that in the debug menu you have enabled 'Visible Collision Shapes'. Now let's run the game!
We will observe two planets intersecting with the query that we performed on the space state. Remember that the CollisionShape2D
is just for demonstration purposes to indicate the part where we execute the query. If we examine the output, we observe the following result (in a more organized format):
[
{
"rid":RID(2675764625408),
"collider_id":26508002450,
"collider":"Planet":<CharacterBody2D#26508002450>,
"shape":0
},
{
"rid":RID(2774548873217),
"collider_id":26675774619,
"collider":"@CharacterBody2D@2":<CharacterBody2D#26675774619>,
"shape":0
}
]
Exactly as expected, we see that the query returned two results. Now let's move the second planet a bit more to the right by changing the Vector2
coordinates to 800, 300:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# Programmatically add planet
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = Vector2(800, 300)
add_child(second_planet)
# ...other code
Now let's run the game again:
The query is intersecting now only one planet. We also see this in the output:
[
{
"rid":RID(2675764625408),
"collider_id":26508002450,
"collider":"Planet":<CharacterBody2D#26508002450>,
"shape":0
}
]
Great! It seems to work.
Filtering the collisions
For my game, I want to detect if there is a planet nearby. However, with the current script, it will also detect other areas or bodies. Let's demonstrate that by creating a new scene, "Player," which is just a basic setup of a CharacterBody2D
, a Sprite2D
, and a CollisionShape2D
.
Now let's link the new Player scene to the game scene and place it close to the planet:
Let's run the game and see what the output will say:
Output:
[
{
"rid":RID(2692944494592),
"collider_id":26860323986,
"collider":"Planet":<CharacterBody2D#26860323986>,
"shape":0
},
{
"rid":RID(2710124363777),
"collider_id":26910655639,
"collider":"Player":<CharacterBody2D#26910655639>,
"shape":0
}
]
As you can see, it now also collides with the Player node. Therefore, we need to filter this array and only return the planets. The result is, in fact, an array with dictionaries containing a collider
key, representing the colliding object. All 2D nodes inherit the Node
class, allowing us to utilize all methods within that class. The method we are going to use is is_in_group()
, which checks if an object belongs to a group. First, we need to ensure that the planets belong to a group. Afterward, we filter the array based on the group.
- Let's open the Planet scene and add the scene to the "Planets" group:
- Open the Game script. Change the following code at step #5:
# ...
# 5. Perform the query
var results = space_state.intersect_shape(shape_query_params)
print(results)
var filtered_array = results.filter(
func(collision_object):
return collision_object.collider.is_in_group("Planets")
)
print(filtered_array)
# ...
- Run the game...
We observe two distinct outputs: one before and one after filtering the array:
# Before filtering...
[
{
"rid":RID(2692944494592),
"collider_id":26860323986,
"collider":"Planet":<CharacterBody2D#26860323986>,
"shape":0
},
{
"rid":RID(2710124363777),
"collider_id":26910655639,
"collider":"Player":<CharacterBody2D#26910655639>,
"shape":0
}
]
# After filtering...
[
{
"rid":RID(2692944494592),
"collider_id":26860323986,
"collider":"Planet":<CharacterBody2D#26860323986>,
"shape":0
}
]
In the event that no planets are within the vicinity, an empty array [] will be returned. To determine the array's length, we can employ the size() method.
Armed with this understanding, let's bring everything together. I've reorganized some elements and introduced the Vector2 variable check_position. In instances where no planets are detected at that position, a new planet will be added to the scene:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
# 0. Location of new planet:
var check_position: Vector2 = Vector2(800, 300)
# Detect other objects in Space
# 1. Get the PhysicsDirectSpaceState2D
var space_state = get_world_2d().direct_space_state
# 2. Create the PhysicsShapeQueryParameters2D instance
var shape_query_params = PhysicsShapeQueryParameters2D.new()
# 3. Create the Circle shape
var shape = CircleShape2D.new()
shape.radius = 200
# 4. Set the shape and origin
shape_query_params.shape = shape
# Check space around location of new planet
shape_query_params.transform.origin = check_position
# 5. Perform the query
var results = space_state.intersect_shape(shape_query_params)
var filtered_array = results.filter(
func(collision_object):
return collision_object.collider.is_in_group("Planets")
)
# 6. Programmatically add planet if nothing is in range
if filtered_array.size() == 0:
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = check_position
add_child(second_planet)
# 7. Debug
var area_2d = Area2D.new()
# Draw debug circle around location of new planet
area_2d.position = check_position
var collision_shape_2d = CollisionShape2D.new()
var collision_shape = CircleShape2D.new()
collision_shape.radius = shape.radius
collision_shape_2d.shape = collision_shape
area_2d.add_child(collision_shape_2d)
add_child(area_2d)
And the result:
Great. Now let's change the check_position
vector to 400,300:
# ...
func _ready() -> void:
# 0. Location of new planet:
var check_position: Vector2 = Vector2(400, 300)
# ...
Result:
No planet! Last test: it is still allowed to spawn near the player. Set the check_position
vector to 700, 300:
# ...
func _ready() -> void:
# 0. Location of new planet:
var check_position: Vector2 = Vector2(700, 300)
# ...
Result:
Awesome :)
Cleaning up
The code is not really reusable, so let's create a more abstract method that can be used for other groups as well. Let's also make the radius configurable:
func detect_body_in_radius(radius: int, pos: Vector2, group_name: String) -> bool:
# Get the 2D physics space state
var space_state = get_world_2d().direct_space_state
# Create a CircleShape2D and set its radius
var shape = CircleShape2D.new()
shape.radius = radius
# Create PhysicsShapeQueryParameters2D and set its shape and transform
var shape_query_params = PhysicsShapeQueryParameters2D.new()
shape_query_params.shape = shape
shape_query_params.transform.origin = pos
# Perform a shape intersection query in the physics space
var results = space_state.intersect_shape(shape_query_params)
# Filter the results to include only objects in the specified group
var filtered_array = results.filter(func(collision_object): return collision_object.collider.is_in_group(group_name))
# Check if there are any objects in the filtered array
return filtered_array.size() > 0
And finally the complete code:
extends Node2D
var planet: PackedScene = preload("res://scenes/Planet.tscn")
func _ready() -> void:
var check_position: Vector2 = Vector2(650, 300)
var planet_nearby = detect_body_in_radius(200, check_position, "Planets")
if !planet_nearby:
var second_planet = planet.instantiate() as CharacterBody2D
second_planet.position = check_position
add_child(second_planet)
func detect_body_in_radius(radius: int, pos: Vector2, group_name: String) -> bool:
# Get the 2D physics space state
var space_state = get_world_2d().direct_space_state
# Create a CircleShape2D and set its radius
var shape = CircleShape2D.new()
shape.radius = radius
# Create PhysicsShapeQueryParameters2D and set its shape and transform
var shape_query_params = PhysicsShapeQueryParameters2D.new()
shape_query_params.shape = shape
shape_query_params.transform.origin = pos
# Perform a shape intersection query in the physics space
var results = space_state.intersect_shape(shape_query_params)
# Filter the results to include only objects in the specified group
var filtered_array = results.filter(func(collision_object): return collision_object.collider.is_in_group(group_name))
# Check if there are any objects in the filtered array
return filtered_array.size() > 0
Conclusion
The initial solution I attempted turned out to be suboptimal—quite frankly, it was far from ideal. Fortunately, the PhysicsDirectSpaceState2D class provides a convenient method for querying 2D space without the need to create a node beforehand. I found this approach not only more efficient but also considerably beneficial. I hope you find it as helpful as I did!
Happy coding :)
Subscribe to my newsletter
Read articles from Joeri Damme directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by