Detectors
This is the fifth tutorial in a series that covers the creation of a simple true top-down 2D game with pixel graphics, using the Godot Engine, version 4. It follows Light and Shadows and adds object detectors to it.
This tutorial uses Godot 4.3, the regular version, but you could also use the .NET version.
Tweaking Movable Objects
This tutorial revolves around detecting movable objects. But before we get to that part we will tweak the objects a little so that they're easier to push around. Currently two adjacent movable objects can fit exactly in a single tile. However, this causes them to get stuck in passages that are a single tile wide, drastically slowing down the speed at which they can be pushed.
We can improve this by slightly changing the collision shape's size in movable_object.tscn
. The object is eight pixels wide and its collision shape currently matches that. We're going to make the collision shape slightly smaller, reducing its size from 8 to 7.9 in both dimensions.
This is enough to prevent the objects from getting stuck while not noticeably affecting the physics precision.
Detector Scene
We're going to make detectors that act like pressure plates that will get activated by movable objects. Create a new detector.tscn
scene with an Area2D
root node named Detector
. Give it two child nodes: a CollisionShape2D
and a Sprite2D
.
Give the collision shape a new RectangleShape2D
with its size set to 12 pixels.
Give the sprite a new GradientTexture2D
with a square 12×12 gradient, like the movable objects have, but going from white to black. Configure it so it's mostly white, with a black edge, for example starting the transition at 0.571.
Also set its filtering to Nearest and give it an unlit CanvasItemMaterial
, like the player character. Finally, set its Visibility › Modulate color to red. We'll use red to indicate an inactive detector.
Add a few detectors to map.tscn
. They should be drawn on top of the tile map but below the movable objects and the player character. This can be done by putting them below the tile map in the scene hierarchy and above everything else.
Detector Script
To make the detector function we'll attach a new GDScript to the root node of detector.tscn
, saving it as detector.gd
.
Switching Colors
The state of the detector will be indicated by its color. By default it is in an invalid state, for which we'll use red. It also has a valid state, for which we'll use green. Or you might want to use different colors, so let's make them configurable via exported variables. We're only interested in RGB colors and want alpha to always be 1. We can hide the alpha channel in the inspector's color widget by using the @export_color_no_alpha
annotation.
Also export a sprite variable so we can hook up the sprite via the inspector.
extends Area2D
## Color for when no objects are on the detector.
@export_color_no_alpha var invalid_color := Color.RED
## Color for when objects are on the detector.
@export_color_no_alpha var valid_color := Color.GREEN
@export var sprite: Sprite2D
To make the detector react to objects entering and exiting its area, hook up its body_entered()
and body_exited()
signals to new functions.
We start with the most straightforward implementation, which is to set the sprite's modulate color to the valid color when something entered and to the invalid color when something exited. These signal functions have a body parameter, but we won't make use of it, so prepend it with an underscore.
func _on_body_entered(_body: Node2D) -> void:
sprite.modulate = valid_color
func _on_body_exited(_body: Node2D) -> void:
sprite.modulate = invalid_color
Only Detecting Movable Objects
There are two problems with this naive approach. The most obvious mistake is that the player character is also detected. We could solve this by checking the type of the body that entered or existed the area, but this is not necessary. We will limit the collisions that the area detects instead. Change the area's CollisionObject2D › Collision › Mask so it only detects things on layer 2. As everything is currently on layer 1 this means that nothing will get detected. Also, we can disable Area2D › Monitorable because the detectors do not need to be detected themselves.
Now switch to movable_object.tscn
and make the object exist on both layer 1 and layer 2.
Now the detectors only react to the presence of movable objects.
Counting Objects
The second problem is that the detectors always become invalid when an object exits their body, even if there are still other objects on it. To solve this we have to keep track of how many objects are on the detector and only set the color when appropriate.
Add an integer variable to track the object count, set to zero by default. When something entered, only change the color if the current object count is zero, then increment the object count. When something exited, decrement the object count and then only change the color when the current object count is zero.
var object_count := 0
func _on_body_entered(_body: Node2D) -> void:
if object_count == 0:
sprite.modulate = valid_color
object_count += 1
func _on_body_exited(_body: Node2D) -> void:
object_count -= 1
if object_count == 0:
sprite.modulate = invalid_color
Pulsing Invalid Color
Our detectors are now fully functional. But let's add something extra to make it more obvious that a detector doesn't have an object on it. We'll do this by making its invalid color pulse, continually adjusting its brightness.
First, add a variable to configure the pulse frequency, with a range of 0.1–1.0 and a default of 0.25, which makes a single pulse take four seconds.
## How fast the detector's color pulses.
@export_range(0.1, 1.0) var pulse_frequency := 0.25
Second, add a float variable to track the pulse progress. Increase this progress in a new _process()
function by the time delta multiplied with the pulse frequency. Then determine the brightness by taking the cosine of the progress multiplied with τ. Do all this only when there are no objects on the detector.
var pulse_progress := 0.0
func _process(delta: float) -> void:
if object_count == 0:
pulse_progress += delta * pulse_frequency
var brightness := cos(pulse_progress * TAU)
The cosine goes from 1 to −1, which is not valid for colors. Multiply it with ¼ and add ¾ so the brightness will have a range of ¾–1.
var brightness := cos(pulse_progress * TAU) * 0.25 + 0.75
Then create a new color by taking the RGB components of the invalid color and multiplying them with the brightness. We cannot directly multiply with the entire color because that would also scale its alpha component, which should remain 1.
var brightness := cos(pulse_progress * TAU) * 0.25 + 0.75
sprite.modulate = Color(
invalid_color.r * brightness,
invalid_color.g * brightness,
invalid_color.b * brightness
)
Also, the pulse progress can wrap back to zero once it reaches 1. We can do this by subtracting 1 if it's at least 1. This isn't strictly necessary but makes sense for a value that indicates the progress of a single pulse.
pulse_progress += delta * pulse_frequency
if pulse_progress >= 1.0:
pulse_progress -= 1.0
Let's reset the pulse progress when the last object exited when area, so the pulse starts at full brightness. Also, we no longer need to change to the invalid color here.
func _on_body_exited(_body: Node2D) -> void:
object_count -= 1
if object_count == 0:
#sprite.modulate = invalid_color
pulse_progress = 0.0
Finally, set the progress to a random 0–1 value in a new _init()
function so the detector pulses aren't synchronized when the game starts.
func _init() -> void:
pulse_progress = randf()
We now have functional object detectors. In the next tutorial we'll use them to trigger a win state that will allow us to progress to another map.