Animated Ghost Trail

The trail we saw in the previous lesson works for a static sprite. What if we want a trail following an animated character?

Using particles, if we change their texture, the texture of every particle already emitted changes at once.

In this tutorial, we’re going to see how to make a ghost trail as in 2D Castlevania games.

You are going to learn to:

Fading Sprite

Let’s create a new scene with a Sprite as root, rename it to FadingSprite. Then add a Tween as its child.

Drag the alienGreen_stand.png image to the Texture as a placeholder. To give it a ghostly look, we can change the FadingSprite > Modulation color to a semi-transparent blue.

Time to code! Attach a script to the FadingSprite. We need the FadingSprite to fade over a lifetime to full transparency then delete itself when the fade finishes.

Let’s export a lifetime variable to set its value through the inspector and create a reference for the Tween; let’s call it _tween.

extends Sprite

export var lifetime := 0.5

onready var _tween := $Tween

Now for the fade, let’s encapsulate this behavior in a fade method. It optionally receives a duration argument, but it takes the lifetime value by default.

For that, we can use the Tween and animate the FadingSprite > Modulation using the interpolate_property. We need to store a transparent version of the FadingSprite > Modulation color to interpolate to. Then, using the duration argument for the interpolation duration.

func fade(duration := lifetime) -> void:
    # Creates a full transparent color based on the Sprite's Modulation
    var transparent := modulate
    transparent.a = 0.0

    # Interpolates from the current modulate to the transparent version
    _tween.interpolate_property(self, "modulate", modulate, transparent, duration)
    _tween.start()

    # Waits the interpolation finish then deletes itself
    yield(_tween, "tween_all_completed")
    queue_free()

To delete the sprite as soon as the _tween finishes the interpolation, we can yield until the _tween emits its tween_all_completed signal.

Since we want the FadingSprite to fade as soon as it enters the scene, we can call the fade() method on the _ready() callback.

# Sprite that automatically fades itself
extends Sprite

export var lifetime := 0.5

onready var _tween := $Tween


func _ready() -> void:
    fade()


func fade(duration := lifetime) -> void:
    # Creates a full transparent color based on the Sprite's Modulation
    var transparent := modulate
    transparent.a = 0.0

    # Interpolates from the current modulate to the transparent version
    _tween.interpolate_property(self, "modulate", modulate, transparent, duration)
    _tween.start()

    # Waits the interpolation finish and deletes itself
    yield(_tween, "tween_all_completed")
    queue_free()

The FadingSprite is ready. Now, we need to spawn instances of it behind the player’s character.

Spawning the ghosts

For our effect, we need to create copies of FadingSprites every second to follow the character’s movement, just like in the Particles2D approach. We also want to control when ghosts can be created to sync them with the character’s movement and animations.

Create a new scene and add a Sprite as root. This is the sprite we are going to use for the player’s graphics. Rename it as AnimatedGhostTrail.

Add a Timer as a child of the AnimatedGhostTrail. We are going to use the Timer to spawn a new ghost, so let’s call it GhostSpawnTimer.

Let’s use the alienGreen_stand.png image as a placeholder for the AnimatedGhostTrail > Texture property as well.

Time to code again, attach a new GDScript to the AnimatedGhostTrail, and open it.

Start with a preload of the FadingSprite PackedScene file at res://GhostTrail/FadingSprite.tscn so we can instance it anytime in our script.

Using preload(), we read the FadingSprite PackedScene file from disk and have it ready at compile-time, unlike load() which fetches the resource at run-time when the line executes. To prevent performance issues, it’s a good practice to store Resources in constants and load them with preload() whenever possible.

Then, export an int variable so we can set the amount of ghosts we want to spawn per second. Also, export a bool variable, so we can tell if when the AnimatedGhostTrail is_emitting and create a reference for the GhostSpawnTimer, call it _timer.

# Spawns copies of FadingSprites per second
extends sprite

const FadingSprite: PackedScene = preload("res://GhostTrail/FadingSprite.tscn")

# The number of ghosts instanced per second
export var amount := 10.0
# If `true`, creates `amount` instances of FadingSprite every second
export var is_emitting := false setget set_is_emitting

onready var _timer := $GhostSpawnTimer

The AnimatedGhostTrail main behavior is to create FadingSprite instances that mirror the current AnimatedGhostTrail properties.

In Godot, we can instance scenes using the PackedScene.instance() method. Since the FadingSprite constant, we’ve created a PackedScene we can call instance() on it and store it in a new ghost variable.

func _spawn_ghost() -> void:
    # Creates an instance of the FadingSprite PackedScene
    var ghost := FadingSprite.instance()

Now we are going to sync the new ghost properties to the AnimatedGhostTrail’s. The most important one is its texture.

func _spawn_ghost() -> void:
    # Creates an instance of the FadingSprite PackedScene
    var ghost := FadingSprite.instance()

    # Sync ghost instance's properties to match AnimatedGhostTrail's
    ghost.texture = texture
    ghost.offset = offset
    ghost.flip_h = flip_h
    ghost.flip_v = flip_v
    ghost.global_position = global_position

In Godot, you need to add a node to the scene tree manually. To add the ghost to the scene, we need to make it a child of another node. The best choice at this point is the AnimatedGhostTrail itself, so let’s call add_child(ghost) after syncing the ghost properties.

To keep the ghosts in place as the AnimatedGhostTrail moves we need to prevent the AnimatedGhostTrail transform from affecting the ghost. For that we can call set_as_toplevel(true) on each every new ghost as soon as we add it to the SceneTree. The _spawn_ghost method should look like this:

func _spawn_ghost() -> void:
    # Creates an instance of the FadingSprite PackedScene
    var ghost := FadingSprite.instance()

    # Sync ghost instance's properties to match AnimatedGhostTrail's
    ghost.texture = texture
    ghost.offset = offset
    ghost.flip_h = flip_h
    ghost.flip_v = flip_v
    ghost.global_position = global_position

    # Adds the synced ghost to the scene
    add_child(ghost)

    # Prevents AnimatedGhostTrail's transform from affecting the `ghost`
    ghost.set_as_toplevel(true)

Now is time to design how the AnimatedGhostTrail triggers the _spawn_ghost behavior.

Every time we set is_emitting to true, we also start the _timer. It should timeout amount times per second. We also spawn a new ghost as soon as is_emitting becomes true.

The _timer should stop when is_emitting is false. Let’s encapsulate this behavior in the set_is_emitting method to ensure this always happens the AnimatedGhostTrail > Is Emitting changes.

func set_is_emitting(value: bool) -> void:
    is_emitting = value
    if not is_inside_tree():
        yield(self, "ready")

    if is_emitting:
        _spawn_ghost()

        # Sets the `_timer` to timeout `amount` times per second
        _timer.start(1.0 / amount)
    else:
        _timer.stop()

When the _timer emits is timeout signal we need spawn a new ghost. So let’s connect the GhostSpawnTimer timeout signal to the _on_Timer_timeout callback:

func _on_GhostSpawnTimer_timeout():
    _spawn_ghost()

And with that, we have our AnimatedGhostTrail working. You can check out the complete AnimatedGhostTrail script in the GhostTrail/AnimatedGhostTrail.gd file.

Usage

In our demo scene, we set the AnimatedGhostTrail > Is Emitting to true whenever the character jumps, and we set it to false when the character reaches the floor. But since the Is Emitting is available in the Inspector dock, we can create animations that toggle it as we want to use keyframes.