Energy Barriers
This is the sixteenth and last 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 Teleporter Tooling and adds energy barriers to our game.
This tutorial uses Godot 4.6, the regular version, but you could also use the .NET version.
Energy Barrier
The last gameplay element that we'll add to our game is an energy barrier. This barrier blocks the player but lets movable objects through. We'll make it eight pixels thick and give it a configurable length.
The scene for the energy barrier is similar to the conveyor scene. We'll put everything related to it in an energy_barrier
folder. Create the energy_barrier.tscn
in there with a StaticBody2D root node so it can block stuff. Give it a CollisionShape2D child node with a new RectangleShape2D that has its Local to Scene option turned On. Also add a Line2D child node with its Points set to two zero points, its Width set to 8, and its Fill › Texture Mode set to Tile.
The line won't need a texture because we'll create its visuals procedurally in the shader, like the dotted teleporter lines. For this purpose we give it a new canvas item Material that is Local to Scene, with a new energy_barrier.gdshader
Shader.
For now we make the shader draw the line with the default white color and its alpha reduce to 0.5. As this is an energy barrier it will adds its visible energy to the scene, so we set its render_mode to both blend_add, to add its color to everything below it, and to unshaded so it is not affected by lighting.
shader_type canvas_item;
render_mode blend_add, unshaded;
void fragment() {
COLOR.a = 0.5;
}
Attach a new energy_barrier.gd
tool script to the root node and give it exported variables for its length, collision shape, and line. Give the length a setter like the conveyor to set up the line and collision shape correctly.
@tool
extends StaticBody2D
@export_range(12, 400) var length := 32 :
set(new_length):
length = new_length
if line and line.points.size() == 2 and line.points[1].x != length:
line.points[1] = Vector2(length, 0.0)
collision_shape.position = Vector2(length * 0.5, 0.0)
var rect := collision_shape.shape as RectangleShape2D
rect.size = Vector2(length, 8.0)
@export var collision_shape: CollisionShape2D
@export var line: Line2D
Hook everything up and tweak its length via the inspector so everything is set up correctly in the scene. Finally, to not block movable objects we have to disable layer 1 of the root node's CollisionObject2D › Collision › Layer. This will also make the player character pass through it, so we'll have to rely on an unused layer to block it. So enable layer 3.
Go to player_character.tscn
and enable collisions with layer 3 via CollisionObject2D › Collision › Mask of the root node, so both 1 and 3 are active. Now only the player character gets blocked by the energy barrier. I added a fifth map with energy barriers to demonstrate this.
Wiggling Lines
The energy barrier is functional but is ugly. We're going to make it look like a wavy energy pattern with wiggly lines so it is more lively and obviously not solid. Go back to energy_barrier.tscn
and look at the barrier at its default length of 32. It is currently just a uniform gray line.
We're going to start by turning this into a narrower waving line. Introduce a wave variable in the fragment() function of energy_barrier.gdshader
. We'll use this single value to set the RGB components of the color, making it grayscale. If we start with the constant 1 it is still a uniform gray because of its opacity.
void fragment() {
float wave = 1.0;
COLOR.rgb = vec3(wave);
COLOR.a = 0.5;
}
We're going to turn this into a vertical gradient to make a fuzzy thinner line. We use the V texture coordinate for this. If we use it directly we get a gradient covering the entire thickness of the barrier.
float wave = UV.y;
We want it to go from 0 at both edges to 1 in the middle. So it has to cover a range of 2, which we do by doubling V. Then we subtract 1 to shift the range from 0–2 to −1–1.
float wave = UV.y * 2.0 - 1.0;
We cannot see negative color values, so take the absolute value of this, turning −1–0–1 into 1–0–1.
float wave = abs(UV.y * 2.0 - 1.0);
Now we have a mirrored gradient that gives us the opposite of what we want. So we can get the desired results by subtracting that from 1. But we also want a more pleasing gradient, which we can do by using the smoothstep() function. If we make it go from 1 to 0 then we also invert the gradient at the same time.
float wave = abs(UV.y * 2.0 - 1.0);
wave = smoothstep(1.0, 0.0, wave);
We can make the line thinner by changing the range of smoothstep(). Let's make it start at 0.5 instead.
wave = smoothstep(0.5, 0.0, wave);
This gives us some room to move our visible line up and down inside the line's geometry. We can turn it into a sine wave by adding the sine of the U coordinate to the transformed V coordinate, before taking its absolute.
float wave = abs(UV.y * 2.0 - 1.0 + sin(UV.x));
We have to reduce the amplitude of the of the wave so it stays inside the bounds. Multiply the sine with 0.25 so there's plenty of space remaining. Let's also increase its frequency so it wiggles more, multiplying U with 2.5.
float wave = abs(UV.y * 2.0 - 1.0 + sin(UV.x * 2.5) * 0.25);
Finally, to animate it subtract TIME from U so it goes from left to right, scaled by 10 to make it go fast.
float wave = abs(
UV.y * 2.0 - 1.0 + sin(UV.x * 2.5 - TIME * 10.0) * 0.25
);
Now that we have a functional wave let's put the code the create it in a separate wave() function. To make it configurable give it float parameters for its wiggle, speed, and energy. The wiggle controls its frequency, replacing the constant 2.5. The speed scales the time influence, replacing the constant 10. The energy determines the line's thickness, replacing the constant 0.5. Finally, we also have to provide the UV coordinates via a parameter, as UV is only directly available in fragment().
float wave(float wiggle, float speed, float energy, vec2 uv) {
return smoothstep(energy, 0.0, abs(
uv.y * 2.0 - 1.0 + sin(uv.x * wiggle - TIME * speed) * 0.25
));
}
void fragment() {
float wave = wave(2.5, 10.0, 0.5, UV);
//wave = smoothstep(0.5, 0.0, wave);
COLOR.rgb = vec3(wave);
COLOR.a = 0.5;
}
It is now easy to add a second wave to make the energy barrier more interesting. Let's introduce an intermediate waves() function for this, with parameters for the energy and the UV coordinates. It creates two waves, using 2.5 and 10 for the first wave's wiggle and speed, and 4 and −8 for the second wave so it looks different and moves in the opposite direction. We use the same energy for both waves. The function returns the two waves as the components of a vec2.
vec2 waves(float energy, vec2 uv) {
return vec2(
wave(2.5, 10.0, energy, uv),
wave(4.0, -8.0, energy, uv)
);
}
void fragment() {
//float wave = wave(2.5, 4.0, 0.5, UV);
vec2 waves = waves(0.5, UV);
COLOR.rgb = vec3(waves.x);
COLOR.a = 0.5;
}
Now we can use the two waves to create a color. The simplest way is to use one wave per color component. Let's use the first wave for R and the second wave for B.
COLOR.rgb = vec3(waves.x, 0.0, waves.y);
However, this makes the pattern hard to see as it is too dark. We could add a third wave, but that would make the pattern too busy. So let's instead use the average of both waves for G. This way we get white where both waves overlap.
COLOR.rgb = vec3(waves.x, (waves.x + waves.y) * 0.5, waves.y);
This looks fine, but it is now hard to see exactly how thick the barrier is. So we'll add a little uniform gray back for the sake of gameplay, scaling the wave color by 0.9 and adding uniform 0.1.
COLOR.rgb = vec3(waves.x, (waves.x + waves.y) * 0.5, waves.y);
COLOR.rgb = COLOR.rgb * 0.9 + 0.1;
Of course the game itself plays at a very low resolution, so run the game to see how the pattern looks when it is only eight pixels wide.
Energy Fading
Until now we've always used a constant energy of 0.5, but we can vary this to adjust the thickness of the pattern. We're going to use this to fade out the waves at the endpoints of the barrier, by reducing its energy to zero there. To make this look good we'll also use the energy for the alpha channel of the color, affecting the barrier's opacity. Create a separate energy() function for this, use it to get the energy in fragment(), and apply it to the waves and alpha channel. To make the energy vary along the length of the barrier we have to pass the U coordinate to the function.
float energy(float u) {
float energy = 0.5;
return energy;
}
void fragment() {
float energy = energy(UV.x);
vec2 waves = waves(energy, UV);
COLOR.rgb = vec3(waves.x, (waves.x + waves.y) * 0.5, waves.y);
COLOR.rgb = COLOR.rgb * 0.9 + 0.1;
COLOR.a = energy;
}
Beginning with the left side, the easy way to fade out the energy is to introduce an edge-fade factor and set it to the U coordinate. That way it starts at zero. Multiply it with the base energy, which we set to 0.5.
float energy(float u) {
float energy = 0.5;
float edge_fade = u;
return energy * edge_fade;
}
This will make the edge fade factor go from 0 to 1 across one texture segment, so eight pixels in the game. But it will keep increasing past that point, so the energy level explodes the further we go. We limit this by clamping the edge fade factor to 1, taking the minimum of U and 1.
float edge_fade = min(u, 1.0);
We have to do the same for the right side and then multiple both parts to fade on both sides of the line. We fade on the right by subtracting U from the maximum coordinate value. But we currently don't know that value, so let's start by using 3.
float edge_fade = min(u, 1.0) * min(3.0 - u, 1.0);
This makes the line fade out after three texture segments. To make it fade out at the real end of the barrier we have to know how many segments there are. So add a uniform float segment_count variable to the shader and use that in energy() instead of our placeholder constant.
shader_type canvas_item;
render_mode blend_add, unshaded;
uniform float segment_count = 1.0;
…
float energy(float u) {
float energy = 0.5;
float edge_fade = min(u, 1.0) * min(segment_count - u, 1.0);
return energy * edge_fade;
}
Set the corresponding shader parameter in the length setter of energy_barrier.gdscript
.
@export_range(12, 400) var length := 32 :
set(new_length):
length = new_length
if line and line.points.size() == 2 and line.points[1].x != length:
line.points[1] = Vector2(length, 0.0)
collision_shape.position = Vector2(length * 0.5, 0.0)
var rect := collision_shape.shape as RectangleShape2D
rect.size = Vector2(length, 8.0)
if line:
line.material.set(
"shader_parameter/segment_count",
length / 8.0
)
Adjust and reset the length to set things up correctly, and then the edge fading is correct.
Energy Pulses
Having two regular waves is not very interesting. To add some variety we'll introduce an energy pulse that travels along the line from left to right. Introduce a pulse() function for this, with parameters for a speed, offset, and U coordinate. It scales TIME by the speed to create an increasing value, then adds an offset so we can make it start at different points along the barrier. To make it loop back to the start when it reaches the end of the line take the modulo of that and the segment count. Subtract the U coordinate from it to make it vary across the line and take the absolute of it to make it symmetrical.
Get a pulse in energy() with a speed of 2 and no offset. Use it for the energy instead of the constant 0.5. Let's also temporarily disable edge fading so we can better evaluate the traveling pulse.
float pulse(float speed, float offset, float u) {
return abs(mod(TIME * speed + offset, segment_count) - u);
}
float energy(float u) {
float pulse = pulse(2.0, 0.0, u);
float energy = pulse;
float edge_fade = min(u, 1.0) * min(segment_count - u, 1.0);
return energy;// * edge_fade;
}
This gives us a negative energy pinch instead. To turn it into a positive and a smoother pulse we once again apply smoothstep() from 1 to 0. We do this in energy() because we'll combine multiple pulses later.
float energy = smoothstep(1.0, 0.0, pulse);
The resulting energy pulse loops back when its center reaches the end of the line, appearing centered on the beginning of the line. This makes it start and loop back too early. This can be fixed by adding two segments to its loop and shifting it back one segment, so it fully moves past the end of the line and starts in front of the beginning of the line.
float pulse(float speed, float offset, float u) {
return abs(mod(TIME * speed + offset, segment_count + 2.0) - u - 1.0);
}
Now that the pulse loops correctly we can turn on edge fading again. We'll also adjust the energy so its minimum is always 0.5 and the pulse adds 0.5 on top. That way the wiggles are always visible and the pulse brightens and enlarges them.
float energy = smoothstep(0.0, 1.0, max(0.0, 1.0 - pulse));
energy = energy * 0.5 + 0.5;
float edge_fade = min(u, 1.0) * min(segment_count - u, 1.0);
return energy * edge_fade;
There is only a single pulse per energy barrier, which is fine for short barriers but long barriers could use more pulses.
Let's just give four pulses to all barriers. Add two uniform vec4 variables, one for pulse offsets and another for pulse speeds. Initialize the offsets to zero and let's use 2, 6, −4, and −8 for the speeds so two pulses move in the opposite direction.
Use these variables to create four pulses in energy() and combine them by taking their minimum, remembering that these act as pinches. Then use the merged pulses to determine the energy.
uniform float segment_count = 1.0;
uniform vec4 pulse_offsets = vec4(0.0);
uniform vec4 pulse_speeds = vec4(2.0, 6.0, -4.0, -8.0);
…
float energy(float u) {
float pulse_x = pulse(pulse_speeds.x, pulse_offsets.x, u);
float pulse_y = pulse(pulse_speeds.y, pulse_offsets.y, u);
float pulse_z = pulse(pulse_speeds.z, pulse_offsets.z, u);
float pulse_w = pulse(pulse_speeds.w, pulse_offsets.w, u);
float merged_pulses = min(min(pulse_x, pulse_y), min(pulse_z, pulse_w));
//float pulse = pulse(2.0, 0.0, u);
float energy = smoothstep(1.0, 0.0, merged_pulses);
energy = energy * 0.5 + 0.5;
float edge_fade = min(u, 1.0) * min(segment_count - u, 1.0);
return energy * edge_fade;
}
Barrier Variety
Our energy barriers are lively, but they all animate the same. This is especially obvious when two barriers of the same length are close together. Let's break the similarity by giving the pulses random offsets along the barrier's entire length. Give energy_barrier.gdscript
a _ready() function that randomly sets the pulse offsets. This will produce different results every time a map is loaded, also in the editor.
func _ready() -> void:
if line:
line.material.set(
"shader_parameter/pulse_offsets",
Vector4(randf(), randf(), randf(), randf()) * (length / 8.0)
)
Next, we also slightly vary the speeds of the pulses so each barrier animates a little different to break the monotony. Initialize the pulse speeds to random ranges so they deviate a bit from the values that we used so far.
line.material.set(
"shader_parameter/pulse_offsets",
Vector4(randf(), randf(), randf(), randf()) * (length / 8.0)
)
line.material.set(
"shader_parameter/pulse_speeds",
Vector4(
randf_range(1.8, 2.2),
randf_range(5.5, 6.5),
randf_range(-3.6, -4.4),
randf_range(-7.6, -8.4),
)
)
Finally, let's also vary the wiggles and speeds of the waves a bit. Introduce a uniform vec4 wave_wiggles_speeds variable for this in the shader. Its X and Y components contain the wiggles while its Z and W components contains the speeds for two waves.
uniform vec4 pulse_offsets = vec4(0.0);
uniform vec4 pulse_speeds = vec4(2.0, 6.0, -4.0, -8.0);
uniform vec4 wave_wiggles_speeds = vec4(2.5, 4.0, 10.0, -8.0);
…
vec2 waves(float energy, vec2 uv) {
return vec2(
wave(
wave_wiggles_speeds.x, wave_wiggles_speeds.z, energy, uv
),
wave(
wave_wiggles_speeds.y, wave_wiggles_speeds.w, energy, uv
)
);
}
Randomly initialize these as well in _ready().
line.material.set(
"shader_parameter/pulse_speeds",
Vector4(…)
)
line.material.set(
"shader_parameter/wave_wiggles_speeds",
Vector4(
randf_range(1.0, 3.0),
randf_range(3.5, 4.5),
randf_range(9.0, 11.0),
randf_range(-7.0, -9.0),
)
)
There are many more gameplay elements that could be added to the game, but this concludes the True Top-Down 2D tutorial series.