Sequencing scenes

In a visual novel, you will not only have one scene but instead many of them. And as our scenes are not Godot scenes, we need to write some code to load and play them one after the other.

In this lesson, we will add the ability to load scenes from a text file.

We’ll also code the Main node, which will create individual scene players and play scenes sequentially.

Saving and loading scenes

So far, we defined our data directly in the ScenePlayer script. This is convenient until we have access to a dialogue editor, as the GDScript compiler will warn us whenever our syntax is incorrect.

But it’s inconvenient if we want to write many scenes. We need a way to save and load scenes to and from the hard drive.

As a scene is just a big dictionary, we can save it in Godot’s native text format. We use the GDScript built-in function str2var() to convert a file’s content into a dictionary.

We can then use its complementary function var2str() to load and convert the data back into a GDScript dictionary.

For more information on these functions, you can check our savegames guide in the best practices chapter. It covers the various approaches you can take to save and export data in Godot.

Open ScenePlayer.gd and add the following functions.

## Loads a scene from `file_path`.
func load_scene(file_path: String) -> void:
    var file := File.new()
    file.open(file_path, File.READ)
    _scene_data = str2var(file.get_as_text())
    file.close()


## Saves a dictionary representing a scene to the disk using `var2str`.
func _store_scene_data(data: Dictionary, path: String) -> void:
    var file := File.new()
    file.open(path, File.WRITE)
    file.store_string(var2str(data))
    file.close()

The code above allows you to alter the scene data directly in a GDScript file, which will give you a compilation error if the data is incorrect, and save the result in Godot’s text format.

You can write a scene’s data directly in the _scene_data variable and save it by calling _store_scene_data() in the _ready() function.

func _ready() -> void:
    _store_scene_data(_scene_data, "res://Scenes/1.scene")

Doing so creates a file directly in your project upon running the scene.

I invite you to create two scene files like that by making sure that you change the file path between the two calls to _store_scene_data().

Here are two minimal scenes that test all our system’s features. To follow along, you can save those directly as new 1.scene and 2.scene files inside of the Scenes/ folder. Note you’ll need a text editor external to Godot to do so, as the editor doesn’t allow you to edit arbitrary text files.

Here’s the first file, 1.scene.

{
0: {
"next": 1,
"transition": "fade_in"
},
1: {
"animation": "enter",
"character": "dani",
"next": 2,
"side": "right"
},
2: {
"animation": "enter",
"character": "sophia",
"line": "Hey Dani!",
"next": 3,
"side": "left"
},
3: {
"choices": [ {
"label": "Hi Sophia!",
"target": 6
}, {
"label": "Do I know you?",
"target": 4
} ]
},
4: {
"character": "sophia",
"line": "Of course, you do! I'm your sister, duh.",
"next": 5
},
5: {
"character": "dani",
"line": "Oh...",
"next": 6
},
6: {
"character": "sophia",
"line": "What's up?",
"next": 7
},
7: {
"next": -1,
"transition": "fade_out"
}
}}

Here’s the second file’s content, 2.scene.

{
0: {
"next": 1,
"transition": "fade_in"
},
1: {
"animation": "enter",
"character": "dani",
"next": 2,
"side": "left"
},
2: {
"animation": "enter",
"character": "sophia",
"next": 3,
"side": "right"
},
3: {
"character": "dani",
"line": "Hey, what's ...

...",
"next": 4
},
4: {
"line": "Dani felt he had already lived this scene.",
"next": 5
},
5: {
"character": "sophia",
"line": "What's going on?",
"next": 6
},
6: {
"character": "dani",
"line": "I'm having a deja-vu.",
"next": 7
},
7: {
"next": -1,
"transition": "fade_out"
}
}

Sequencing scenes with the main game node

We will now create the Main scene that will serve as the entry point for our game.

It stores a list of scene files to play in sequence and defines a function to play scenes.

It will also have the responsibility of restarting a scene if the scene player emitted the restart_requested signal.

Create a new scene with a plain Node at the root named Main. Save the scene at the root of your project and attach a script to the node.

We start with the Main node’s state.

We preload the ScenePlayer scene to instantiate it from the code. We also write the sequence of scenes to load and play in a constant.

And to keep track of which scene played last and of the active ScenePlayer instance, we create two pseudo-private variables.

# The main node runs the game by instantiating scene players, one for each scene
# that will play.
# Creating new instances for each scene allows us to start clean every time,
# with the text box and the different game components reinitialized.
extends Node

const ScenePlayer := preload("res://ScenePlayer.tscn")

# We play scenes in a sequence defined in this array. In this demo, it is a
# simple as it gets: we use a list of paths to the scene files.
const SCENES := ["res://Scenes/1.scene", "res://Scenes/2.scene"]

# This variable keeps track of the currently playing scene's index. This allows
# us to restart the current scene if need be.
# At the start of the game, nothing's playing yet, so I initialized the value to
# `-1` to reflect that.
var _current_index := -1
var _scene_player: ScenePlayer

Here comes the heart of our script.

We define a _play_scene() function that updates the _current_index, frees any existing scene player, and instantiates a new one.

There, we also load the next scene, connect to the ScenePlayer’s signals, and run the scene.

When we receive a signal, we play the next scene until we have none left, in which case we end the game.

# At the start of the game, we play the first scene
func _ready() -> void:
    _play_scene(0)


## Plays the scene by instantiating as seen player and adding it as a child. The
## scene player handles loading and running the scene.
func _play_scene(index: int) -> void:
    # We update the index and ensure it's valid.
    _current_index = int(clamp(index, 0.0, SCENES.size() - 1))

    # If a scene just ended, we free it.
    if _scene_player:
        _scene_player.queue_free()

    # We instantiate a new ScenePlayer, load a scene, and connect to its signals.
    _scene_player = ScenePlayer.instance()
    add_child(_scene_player)
    _scene_player.load_scene(SCENES[_current_index])

    _scene_player.connect("scene_finished", self, "_on_ScenePlayer_scene_finished")
    _scene_player.connect("restart_requested", self, "_on_ScenePlayer_restart_requested")
    # And we run the scene.
    _scene_player.run_scene()


# When the ScenePlayer emits the `scene_finished` signal, we play the next scene
# or end the game.
func _on_ScenePlayer_scene_finished() -> void:
    # If the scene that ended is the last one, we're done playing the game.
    if _current_index == SCENES.size() - 1:
        return

    _play_scene(_current_index + 1)


func _on_ScenePlayer_restart_requested() -> void:
    _play_scene(_current_index)

You can press F5 to run the project’s main scene, and set it to Main.tscn.

You should see the two scenes play in sequence and end with a black screen.

Creating a separation between the main node in the scene player may not seem necessary. But there are at least three reasons to do so:

  1. It creates a clear separation of concern between what plays one scene and what sequences them.
  2. By deleting ScenePlayer instances and creating fresh ones every time we start a scene, we ensure that our TextBox, CharacterDisplayer, and other components always spawn in the same state.
  3. When we delete a ScenePlayer, the main node is still there, and it keeps track of the game state.

In our demo, the three benefits above are already present, and they’ll become even clearer as your codebase grows in size.

Closing thoughts

We’re getting to the end of this series. You already have the foundations to build either a complete dialogue system or a visual novel game.

The system we wrote isn’t perfect. There are edge cases it doesn’t handle, like trying to display a character’s name along with a choice. Right now, if you try that, the game will freeze. You may also want to automatically fade in and out at the start and the end of a scene, instead of writing the transitions manually, those sorts of things.

These features are just unsupported in our demo. In production, you would want to create an interface that prevents your teammates from using any unintended combination or unsupported feature.

Here, you’ve learned the foundations, and the essentials. The core techniques you need to build about any kind of dialogue system. And you can apply them to any game.

If you would like to work with an existing editor, the main difference will be that you have to work with the data it outputs rather than define your own.

Or you could translate the data your editor exports into the format your game expects, whichever feels best to you.

Another option, if you’re not interested in coding a completely custom and unique dialogue system, is to use a premade one, like the one that ships with Dialogic.

It will save you time, but you won’t be able to make it stand out. Like any premade game system, it’s a tradeoff.

I want to thank you for getting to this point and hope you enjoyed the series. 

What did you learn? Did you enjoy it?

I would love to hear your feedback on it, which you can write using the “ask a question” form below the lesson on Mavenseed.

Code reference

ScenePlayer.gd

class_name ScenePlayer
extends Node

signal scene_finished
signal transition_finished
signal restart_requested

const KEY_END_OF_SCENE := -1
const KEY_RESTART_SCENE := -2

const TRANSITIONS := {
    fade_in = "_appear_async",
    fade_out = "_disappear_async",
}

var _scene_data := {}

onready var _text_box := $TextBox
onready var _character_displayer := $CharacterDisplayer
onready var _background := $Background
onready var _anim_player: AnimationPlayer = $FadeAnimationPlayer


func run_scene() -> void:
    var key = _scene_data.keys()[0]
    while key != KEY_END_OF_SCENE:
        var node: Dictionary = _scene_data[key]

        var character: Character = (
            ResourceDB.get_character(node.character)
            if "character" in node
            else ResourceDB.get_narrator()
        )

        if "background" in node:
            var bg: Background = ResourceDB.get_background(node.background)
            _background.texture = bg.texture

        if "character" in node:
            var side: String = node.side if "side" in node else CharacterDisplayer.SIDE.LEFT
            var animation: String = node.get("animation", "")
            var expression: String = node.get("expression", "")
            _character_displayer.display(character, side, expression, animation)
            if not "line" in node:
                yield(_character_displayer, "display_finished")

        if "line" in node:
            _text_box.display(node.line, character.display_name)
            yield(_text_box, "next_requested")
            key = node.next
        elif "transition" in node:
            call(TRANSITIONS[node.transition])
            yield(self, "transition_finished")
            key = node.next

        elif "choices" in node:
            _text_box.display_choice(node.choices)
            key = yield(_text_box, "choice_made")
            if key == KEY_RESTART_SCENE:
                emit_signal("restart_requested")
                return
        else:
            key = node.next

    _character_displayer.hide()
    emit_signal("scene_finished")


func load_scene(file_path: String) -> void:
    var file := File.new()
    file.open(file_path, File.READ)
    _scene_data = str2var(file.get_as_text())
    file.close()


func _appear_async() -> void:
    _anim_player.play("fade_in")
    yield(_anim_player, "animation_finished")
    yield(_text_box.fade_in_async(), "completed")
    emit_signal("transition_finished")


func _disappear_async() -> void:
    yield(_text_box.fade_out_async(), "completed")
    _anim_player.play("fade_out")
    yield(_anim_player, "animation_finished")
    emit_signal("transition_finished")


func _store_scene_data(data: Dictionary, path: String) -> void:
    var file := File.new()
    file.open(path, File.WRITE)
    file.store_string(var2str(data))
    file.close()

Main.gd

extends Node

const ScenePlayer := preload("res://ScenePlayer.tscn")

const SCENES := ["res://Scenes/1.scene", "res://Scenes/2.scene"]

var _current_index := -1
var _scene_player: ScenePlayer


func _ready() -> void:
    _play_scene(0)


func _play_scene(index: int) -> void:
    _current_index = int(clamp(index, 0.0, SCENES.size() - 1))

    if _scene_player:
        _scene_player.queue_free()

    _scene_player = ScenePlayer.instance()
    add_child(_scene_player)
    _scene_player.load_scene(SCENES[_current_index])
    _scene_player.connect("scene_finished", self, "_on_ScenePlayer_scene_finished")
    _scene_player.connect("restart_requested", self, "_on_ScenePlayer_restart_requested")
    _scene_player.run_scene()


func _on_ScenePlayer_scene_finished() -> void:
    if _current_index == SCENES.size() - 1:
        return
    _play_scene(_current_index + 1)


func _on_ScenePlayer_restart_requested() -> void:
    _play_scene(_current_index)