Latest Post: Godot 4 CutsceneDirector: A Script-Based Alternative to Timelines

Godot 4 CutsceneDirector: A Script-Based Alternative to Timelines

Discover how to create dynamic and flexible cutscenes in Godot 4 with the CutsceneDirector pattern. Learn how to simplify your workflow, improve scalability, and avoid the complexities of timelines.

5 min read
Logo of Godot in the center of a grid background

Cutscenes are a powerful tool for storytelling in games, but managing them efficiently can be challenging. Even though it is probably possible to exclusively rely on timelines to script their cutscenes, other approaches are possible. Thanks to Nicolas Petton, the creator of Dreamed Away, I discovered a more flexible, script-based approach using a custom CutsceneDirector class. This method leverages signals, asynchronous function calls, and modular cutscene actions, enabling greater control and ease of maintenance compared to traditional timeline approaches.

In this article, I’ll walk you through this pattern, its benefits, and how you can implement it in your Godot 4 projects.

When Timelines Fall Short

While Godot’s timeline system (AnimationPlayer or Tween) is excellent for animations, it can become hard to use for cutscenes requiring multiple interactions like character movement, dialogue, or camera transitions. It is in these cases that timelines can become cumbersome and difficult to manage. However, timelines are still useful for cutscenes where you just need to animate a lot of objects or properties at the same time. The cool thing is that we can also use the timeline system in conjunction with the CutsceneDirector pattern.

So, all in all, either for simple or more complicated cases, the CutsceneDirector approach simplifies the process by creating reusable scripts for cutscene actions and keeping everything in code, making it easier to manage changes, debug issues, and scale.

The CutsceneDirector file

The main file to consider here is the CutsceneDirector script, which extends Node2D and defines reusable cutscene actions like moving characters, fading the screen, fading in and out objects, and loading dialogues. Here’s the script in action with some example actions:

// CutsceneDirector.gd
class_name CutsceneDirector
extends Node2D

signal cutscene_action_done

func _ready() -> void:
    add_to_group("cutscenes")

func call_cutscene_action(method_name: String, args: Array) -> void:
    callv(method_name, args)
    await wait_for_cutscene_action_done()

# This is the same as call_cutscene_action, but with a shorter name
func cca(method_name: String, args: Array) -> void:
    callv(method_name, args)
    await wait_for_cutscene_action_done()

func wait_for_cutscene_action_done() -> void:
    await cutscene_action_done

func wait(time: float) -> void:
    await get_tree().create_timer(time).timeout
    emit_signal("cutscene_action_done")

func wait_and_emit_done(duration: float) -> void:
    await get_tree().create_timer(duration).timeout
    emit_signal("cutscene_action_done")

# Example actions
func move_camera_to_position(starting_position: Vector2, ending_position: Vector2, duration: float) -> void:
    if not $Camera2D:
        return
    $Camera2D.position = starting_position
    var tween = create_tween()
    tween.tween_property($Camera2D, "position", ending_position, duration)
    await tween.finished
    emit_signal("cutscene_action_done")

func fade_in(duration: float) -> void:
    Global.transition_manager_service.screen_fade.fade_in(duration)
    await Global.transition_manager_service.screen_fade.faded_in
    emit_signal("cutscene_action_done")

func fade_out(duration: float) -> void:
	Global.transition_manager_service.screen_fade.fade_out(duration)
	await Global.transition_manager_service.screen_fade.faded_out

	emit_signal("cutscene_action_done")

func fade_in_circle() -> void:
	Global.transition_manager_service.circle_transition.fade_in()
	await Global.transition_manager_service.circle_transition.animation_player.animation_finished
	await Global.wait(0.5)

	emit_signal("cutscene_action_done")

func fade_out_circle() -> void:
	Global.transition_manager_service.circle_transition.fade_out()
	await Global.transition_manager_service.circle_transition.animation_player.animation_finished

	emit_signal("cutscene_action_done")

func appear_node(node: Node) -> void:
	node.visible = true
    print("Appearing node")
    wait_and_emit_done(0.2)

func disappear_node(node: Node) -> void:
	node.visible = false
    wait_and_emit_done(0.2)

func move_character_to_position(character: CharacterNPC, start_position: Vector2, end_position: Vector2, duration: float, animation_type: String, sprite_direction: String) -> void:
	# Set initial position and sprite direction
	character.position = start_position
	character.sprite_direction = sprite_direction

	# Start the animation
	character.state = "walk"
	var animation_to_play = character.animation_map[animation_type][sprite_direction]
	character.animation_player_character.play(animation_to_play)

	# Tween the movement
	var tween = create_tween()
	tween.tween_property(character, "position", end_position, duration)
	await tween.finished

	# Stop the animation (set to idle)
	character.state = "idle"
	character.animation_player_character.play(character.animation_map["STAND"][sprite_direction])

	emit_signal("cutscene_action_done")

func change_sprite_direction(character: CharacterNPC, sprite_direction: String) -> void:
	character.sprite_direction = sprite_direction
	wait_and_emit_done(0.1)

func play_timeline(timeline: AnimationPlayer) -> void:
    timeline.play()
    await timeline.animation_finished
    emit_signal("cutscene_action_done")

func load_dialogue(dialogue_file: DialogueResource, dialogue_name: String) -> void:
	DialogueManager.show_dialogue_balloon(dialogue_file, dialogue_name)
	await DialogueManager.dialogue_ended
	emit_signal("cutscene_action_done")

func change_scene(scene_path: String, hero_position = null, direction: String = Constants.DIRS.DOWN, transition = "fade_normal") -> void:
	Global.scene_manager.cutscene_map_change_requested(scene_path, hero_position, direction, transition)
	emit_signal("cutscene_action_done")

As you can see, this main script includes core features like:

  • Signals: cutscene_action_done ensures actions are processed sequentially.
  • Asynchronous Functions: Using await makes scripting actions intuitive.
  • Modular Actions: Functions like move_camera_to_position, fade_in, wait, or play_timeline are reusable across different cutscenes.

Building a Custom Cutscene

To use the CutsceneDirector, create a script extending it for each cutscene. This allows you to define scene-specific logic while reusing common actions.

extends CutsceneDirector

@onready var eytran: CharacterNPC = $Eytran
@onready var point_a: Marker2D = $PointA
@onready var point_b: Marker2D = $PointB
@onready var point_c: Marker2D = $PointC

# I am using a DialogueResource to store dialogues
var dialogue = preload("res://dialogue/dialogue.dialogue")

func _ready() -> void:
    super()

	await cca("move_camera_to_position", [Vector2(0, 1000), Vector2(0, 0), 4.0])
	await cca("wait", [1.0])
	await cca("appear_node", [eytran])

	# Moving Eytran around
	await cca("move_character_to_position", [eytran, eytran.position, Vector2(point_a.position.x, point_a.position.y), 2.0, "WALK", Constants.DIRS.UP])
	await cca("wait", [0.2])
	await cca("move_character_to_position", [eytran, eytran.position, Vector2(point_b.position.x, point_b.position.y), 1.5, "WALK", Constants.DIRS.RIGHT])
	await cca("wait", [0.2])
	await cca("move_character_to_position", [eytran, eytran.position, Vector2(point_c.position.x, point_c.position.y), 3.5, "WALK", Constants.DIRS.LEFT])
	await cca("wait", [0.2])
	await cca("move_character_to_position", [eytran, eytran.position, Vector2(point_a.position.x, point_a.position.y), 2.0, "WALK", Constants.DIRS.RIGHT])


	# # Welcome Dialogue
	await cca("change_sprite_direction", [eytran, Constants.DIRS.DOWN])
	await cca("load_dialogue", [dialogue, "this_is_a_node_title"])

	# Transition to next scene
	await cca("change_scene", ["another_scene", Vector2(289, 1492), Constants.DIRS.DOWN, "fade_circle"])

We can see here that we can easily define the cutscene actions in a sequential manner, making the script more readable and intuitive. We will also be able to reuse the actions across different cutscenes.

Example of a cutscene using the CutsceneDirector pattern in Godot 4’

Advantages of the CutsceneDirector Approach

  1. Reusability: By encapsulating actions like move_camera_to_position or load_dialogue, you can reuse them across multiple cutscenes, saving time and effort.

  2. Debugging Made Easy: Debugging timeline-based cutscenes can be tedious. With the CutsceneDirector, errors can be pinpointed directly in the script.

  3. Scalability: Adding new cutscene actions (e.g., rotating the camera, spawning effects) is as simple as creating a new function in the CutsceneDirector.

  4. Code Readability: The use of await makes cutscene scripting look sequential and intuitive, reducing complexity.

FAQ about Cutscenes in Godot 4


Share article