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.
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
, orplay_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.
Advantages of the CutsceneDirector Approach
-
Reusability: By encapsulating actions like move_camera_to_position or load_dialogue, you can reuse them across multiple cutscenes, saving time and effort.
-
Debugging Made Easy: Debugging timeline-based cutscenes can be tedious. With the CutsceneDirector, errors can be pinpointed directly in the script.
-
Scalability: Adding new cutscene actions (e.g., rotating the camera, spawning effects) is as simple as creating a new function in the CutsceneDirector.
-
Code Readability: The use of await makes cutscene scripting look sequential and intuitive, reducing complexity.
FAQ about Cutscenes in Godot 4
Share article