The scene player

In this lesson, we will code a system to sequence scenes. It will coordinate actions between the character displayer and the text box we created over the past two parts.

The more your visual novel grows, the more systems and animations you will need to control and coordinate.

That’s the scene player’s role. It should allow you to code a scalable system to empower the writers and designers to direct the game’s scenes.

You may be surprised by the simplicity of the code we’ll write: it will mostly consist of a loop with a growing number of conditional blocks. Each block will delegate to a sub-system like the TextBox.

It’s a solution that may not seem too smart, but that perfectly does the job for this kind of game. We’re keeping it simple.

Creating the base scene

Create a new User Interface scene and name the root node ScenePlayer.

In it, we first add a background. Create a new TextureRect node named Background and apply Layout -> Full Rect to it.

We included a background image in the project, industrial-building.jpg. Drag-and-drop it onto the node’s Texture slot. You also want to turn its Expand property on so the texture expands to the node’s bounds.

As a sibling of the Background, instantiate the CharacterDisplayer and the TextBox. That way, the parent ScenePlayer node will have access to both of them.

We already have everything we need to get coding. Save the scene at the root of your project and attach a script to it.

Setting up the ScenePlayer’s script

We’ll start by setting just the basics of our scene player before designing our data format.

Like our text box in character display, we want to design the scene player so another node can control it and sequence multiple scenes.

Why? Your game will have more than just scenes. It may have a main menu, settings, a world map, maybe some mini-games with different gameplay, not just dialogues and choices.

So we can use a higher-level system to handle the transitions between a menu, a scene, and another part of your game.

To do that, we will define signals on the scene player. The ScenePlayer will play one scene and emit the scene_finished signal once it’s done.

## Loads and plays a scene's dialogue sequences, delegating to other nodes to
## display images or text.
class_name ScenePlayer
extends Node

## Emitted when the scene finished playing. This corresponds to the "next" key
## of the last played node having a value of `-1`.
signal scene_finished

## When we encounter this value in our scene's data, we'll end the scene.
const KEY_END_OF_SCENE := -1

## Stores all the data for the currently playing scene.
var _scene_data := {}

# References to the scene's nodes we'll use in this script.
onready var _text_box := $TextBox
onready var _character_displayer := $CharacterDisplayer
onready var _background := $Background

Using and designing our scene data

A scene is a list of steps to perform, like displaying a text reply or animating a character appearing on one side.

We can represent the steps with data. And before we write code that uses the data, we need to decide its format.

The use of data is essential with any system of this kind as:

  1. It allows you to create or use a tool to author the scenes and design that data, like an editor plug-in.
  2. You can refactor any game system as there is a strict separation between your data and how your scripts work.
  3. Compared to using GDScript directly, it prevents players from injecting unwanted code in your game.

To expand a bit on the second point, we want to have each scene saved in a separate data file.

The scene player loads a file and parses the data. It then looks at one event or step at a time, reads it, and calls functions on the text box, the character displayer, or another system.

It creates a boundary between the data and the details of how the subsystems work, something that generally makes for easy refactoring.

Considerations to design a data format

To design a data format, you can start by writing down the list of every action or event you want to allow in your system.

You want to be clear about how the game is supposed to play. In our visual novel, we want to support choices and, based on these choices, jump to different points in our scene. This requires a different data structure from linear conversations over which you have no control.

You also want to think about how the data scales. Can you adapt it if you add or remove features moving forward?

In this series, we will support the following features:

  1. Displaying a text reply.
  2. Giving the player choices.
  3. Displaying and animating characters.
  4. Changing the background.
  5. Restarting the current scene.
  6. Fading the screen from and to black.

We want to be able to combine some of them in one step, like animating a character and displaying text at the same time. So we need a structure that allows us to run multiple commands or events at once.

Dictionaries are perfect for that. They can contain any number of key-value pairs, and we can quickly check if the key exists or not, allowing us to compose each step in the scene of one or more instructions.

It’s a native datatype in GDScript, allowing us to get started in no time.

Any alternative would be more complicated, like creating a mini-language as seen in the visual novel framework Ren’Py.

To do so, you need to write a language parser and tokenizer, which takes a lot more time. It is mostly an option to consider if you are thinking of creating a custom editor from scratch. Because writing either a complete suite of tools that integrate into the editor or a mini-language might take about the same amount of work.

Writing down some data

Here is how I get started with defining a concrete data format.

I directly write it in GDScript, in the ScenePlayer script in this case, and write some test code like the run_scene() function we’ll implement in a moment.

As we saw above, dictionaries are perfect for our needs, as we can have any number of key-value pairs on them.

A scene can have many steps, so we need to store multiple dictionaries to run in sequence. When designing this tutorial series, I tried using both an array of dictionaries and a dictionary of dictionaries as the content of _scene_data.

To support branching dialogues, I found that the second option was more efficient and convenient. Each value in _scene_data has a corresponding key, a unique ID that any other value can point to, creating a link between steps in our scene.

I call these key-value pairs “nodes” of our scenes. Each node is a dictionary that generally contains a next key, the next node to process after the current one.

Here’s an example to make it clearer. In ScenePlayer.gd, you can update _scene_data to hold the data below.

var _scene_data := {
    0: {
        character = "sophia",
        side = "left",
        animation = "enter",
        line = "Hi there! My name's Sophia.",
        next = 1
        },
    1: {
        character = "dani",
        side = "right",
        animation = "enter",
        next = 2
        },
    2: {
        character = "dani",
        line = "Hey, I'm Dani.",
        next = -1
       }
    }

When designing the format, I look at the list of features it should support and use keys that are as simple as possible. For example, I wanted to display a character on a given side with a particular animation. Hence the keys: character, side, and animation.

In this demo, animations and sides are always about characters, so I kept the keys simple. However, you may prefer to use explicit key prefixes, like writing character_side and character_animation instead.

The first above node makes Sophia enter from the left while displaying the line “Hi there! My name’s Sophia.” Then, Dani enters from the right. Once the animation finishes, he replies, “Hey, I’m Dani.”

To run the corresponding commands, we’ll look at each node’s keys. You can see we’re free to use some keys or not. Our code should take this into account.

The scene reader follows what is essentially a linked list of nodes until one has a next key with a value of -1. This number, which I chose arbitrarily, marks the end of the scene.

Running a scene

With some data in place, we can write the code that will run a scene. To do so, we add a run_scene() function, the heart of our dialogue system.

It is a big loop that processes nodes in our scene data, reads their content, and requests other systems to act upon it.

It starts with the first key-value pair in our _scene_data dictionary. In this demo, all keys are integers. The first key will typically be 0 or 1.

The function reads one node, waits for the events it represents to finish, and then jumps to the node the next key points to. That is until it encounters a value of -1 and breaks out of the loop.

Let’s start coding it. Here, we initialize the loop.

func run_scene() -> void:
    # We first initialize our integer key variable to the first key of our 
    # _scene_data.
    var key = _scene_data.keys()[0]
    # We loop over the nodes until we encounter a `next` key of `-1`.
    while key != KEY_END_OF_SCENE:
        # Every loop iteration, we start by getting the value corresponding 
        # to the key.
        var node: Dictionary = _scene_data[key]

The first feature we can add support for is changing the background, as it’s the simplest.

If the node has a key named background, we fetch the corresponding background from the ResourceDB and assign its texture to the Background node.

#func run_scene() -> void:
    #...
    #while key != KEY_END_OF_SCENE:
        #...

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

Next, we add support for displaying characters.

We’ll repeatedly use a pattern as we don’t want to fail when indexing keys in our node.

We’ll either use conditional branches to test for existing keys or call Dictionary.get(), which allows us to pass a default value as a second argument if the key does not exist.

For example, node.get("animation", "") returns the value "" if the node dictionary does not have the animation key.

To display a character, we need to tell the CharacterDisplayer the side, animation, and expression to use.

We do so with the following code.

        # We first get a valid Character object.
        var character: Character = (
            ResourceDB.get_character(node.character)
            if "character" in node
            else ResourceDB.get_narrator()
        )

        # We only use the CharacterDisplayer if `node` has a `character` key
        if "character" in node:
            # For the side, we must pass a valid defualt value. Here, I chose the
            # left side.
            var side: String = node.side if "side" in node else CharacterDisplayer.SIDE.LEFT

            # Notice the use of `Dictionary.get()`.
            # In case the keys don't exist, we'll get an empty string.
            var animation: String = node.get("animation", "")
            var expression: String = node.get("expression", "")

            # We then pass all the values to the CharacterDisplayer.display() 
            # to display the corresponding character.
            _character_displayer.display(character, side, expression, animation)

            # If you only want to play a character animation but not display
            # some text in this step of the scene, we wait for the character
            # displayer to finish playing the animation.
            if not "line" in node:
                yield(_character_displayer, "display_finished")

Let’s add support for text replies next. These should happen if a node contains the line key and delegate work to the TextBox.

Add this code at the same indent level as the block starting with if "character" in node: so both the character-related and text-related code can run in parallel.

        #...
        if "line" in node:
            # We always get a valid character, even when only displaying some
            # text. It's either provided in the node or the narrator. That's why
            # we can directly access the character's display name.
            _text_box.display(node.line, character.display_name)
            # We then wait for the text box to emit the `next_requested` signal,
            # marking the end of the text reply.
            yield(_text_box, "next_requested")
            # We update the `key` for the the next loop iteration.
            key = node.next
        # This block ensures we don't get stuck in an infinite loop if there's no
        # line or choice to display.
        else:
            key = node.next

You may wonder why we assign the node’s next key inside a conditional block. This is because we want to add support for player choices, which will work differently from regular nodes.

So we’ll have to handle the cases of displaying a regular line of text, animating a character, and letting the player pick a choice separately.

Also, you may notice we have multiple if blocks. As mentioned previously, we may want to both change the background and display some text simultaneously, animate a character without displaying any text, and so on.

Using separate conditional blocks allows for that. When we add choices, we’ll make two options mutually exclusive, as you’ll see in an upcoming lesson.

When we break out of the loop, the scene ends, and we emit the corresponding signal.

At this point, we can optionally hide anything we don’t want to be visible anymore, like the character displayer.

Add the following two lines after and outside the while loop, at the end of the function.

    #while key != KEY_END_OF_SCENE:
        #...

    _character_displayer.hide()
    emit_signal("scene_finished")

To test the code, add a temporary _ready() function and call run_scene(). Before that though, we need to make the text box appear by calling TextBox.fade_in_async().

func _ready() -> void:
    yield(_text_box.fade_in_async(), "completed")
    run_scene()

The little scene will end abruptly as we lack features to fade the scene from and to black. We’ll add that in the next lesson.

Code reference

Here’s the complete ScenePlayer.gd script so far. Note I’ve swapped the order of two code blocks for readability.

class_name ScenePlayer
extends Node

signal scene_finished

const KEY_END_OF_SCENE := -1

var _scene_data := {}

onready var _text_box := $TextBox
onready var _character_displayer := $CharacterDisplayer
onready var _background := $Background


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
        else:
            key = node.next

    _character_displayer.hide()
    emit_signal("scene_finished")