Refining controls and animation

In this lesson, we will refine our dialogue box’s presentation by adding some animation to the name background and label. We also hide the character’s name when the narrator is speaking to clarify that it is not part of the scene.

We will also make the name disappear when displaying a choice to emphasize that it’s not a character speaking.

Finally, we’ll add mouse and touch input to the demo.

It is a little breather before adding more features: a skip button and keeping track of player choices, which we will add in the next lessons.

To do this, we have to update the text box and the name background scripts.

Adding animations to the name background and label

Let’s start by opening the TextBox scene and creating fade animations on the NameBackground and NameLabel nodes. To do so, we’ll use Tween nodes.

But this time, we won’t add the nodes to the tree in the editor. Instead, we’ll create a Tween object and add it to the tree using GDScript.

Why? That’ll allow us to create a self-contained script to attach to any Control node to give it two animations: appear and disappear.

In the FileSystem dock, create a new script named FadingControl in the TextBox/ directory. It should extend Control so we can attach it to any Control node.

Note: In Godot, if a script extends a given type, like Control, you can attach it to any node type that also extends Control, be it a Button, a TextureRect, or another.

Open the script and fill it with the following code.

## Uses a Tween object to animate the control node fading in and out.
extends Control

const COLOR_WHITE_TRANSPARENT := Color(1.0, 1.0, 1.0, 0.0)

## Duration of the fade-in animation in seconds.
## The disappear animation is twice as short.
export var appear_duration := 0.3

# Here, we create a Tween node.
var _tween := Tween.new()


func _ready() -> void:
    # We must add the tween to the scene tree to use it.
    add_child(_tween)
    # Here, we make the node transparent by default.
    modulate = COLOR_WHITE_TRANSPARENT


# Fades in the control.
func appear() -> void:
    _tween.interpolate_property(
        self, "modulate", COLOR_WHITE_TRANSPARENT, Color.white, appear_duration
    )
    _tween.start()
    # Once more, we force the animation to update instantly to avoid potential
    # visual artifacts.
    _tween.seek(0)


# Fades out the control.
func disappear() -> void:
    _tween.interpolate_property(
        self, "modulate", Color.white, COLOR_WHITE_TRANSPARENT, appear_duration / 2.0
    )
    _tween.start()
    _tween.seek(0)

Attach the script to both the NameBackground and the NameLabel nodes.

Next, we have to update the TextBox’s functions. We can now directly call the appear() and disappear() functions on the background and the label.

Open TextBox.gd and in the display() function, delete the following code.

func display(text: String, character_name := "", speed := display_speed) -> void:
    #...
    if character_name != "":
        _name_label.text = character_name

Instead, we add two conditional branches. Add the following code.

func display(text: String, character_name := "", speed := display_speed) -> void:
    #...

    # When the narrator is speaking, we don't want to display its name, so we
    # hide the background.
    # As we know the speaking character's name, we can compare it to the
    # narrator's display name to check if the current character is the narrator.
    if character_name == ResourceDB.get_narrator().display_name:
        _name_background.hide()
    # The following conditions make it so if we are displaying a character's
    # name for the first time in a scene, we play the name label's appear
    # animation.
    elif character_name != "":
        _name_background.show()
        if _name_label.text == "":
            _name_label.appear()

        _name_label.text = character_name

When it comes to choices, we play the NameBackground node’s disappear animation when displaying a choice, and we make it appear again when the player made a choice.

To do so, we add the following calls to the display_choice() and _on_ChoiceSelector_choice_made() functions.

func display_choice(choices: Array) -> void:
    #...
    _name_background.disappear()


func _on_ChoiceSelector_choice_made(target_id: int) -> void:
    #...
    _name_background.appear()

If you test the game, you’ll see the difference is subtle. We’re adding a little bit of polish.

As the text box is so central to the game’s visuals, though, you’ll want to spend that little extra time to make it feel great.

You’ll certainly notice more opportunities to use those animations. Don’t hesitate to do so.

Adding support for mouse and touch input

Our game plays with the keyboard, but it currently doesn’t accept mouse and touch input.

We can add support for both with just a few tweaks. That’s one of the little joys of using Godot.

To do so, we need to update the default input map and our nodes’ Mouse Filter properties.

Godot’s Control nodes receive mouse clicks within their bounds. These events get passed to their special _gui_input() callback, which gets executed before the engine calls _unhandled_input().

This is a powerful system whenever you need to handle clicks in various parts of your interface differently, which is the case in most games.

But not in a visual novel. When we click on the screen, we want the text to always advance, except when the player has to make a choice, for example.

We can make Control nodes ignore mouse clicks. To do so, you can select any and set its Mouse -> Filter to Ignore.

We’ll do that with most nodes. Luckily, if we select multiple nodes at once, we can change any property they share in batch.

In the ScenePlayer scene, select the ScenePlayer, Background, and ColorRect and in the Inspector, set their Mouse -> Filter to Ignore.

Open the TextBox and BlinkingArrow scenes and do the same for all nodes that aren’t scene instances.

With this change, you can already click on buttons in the ChoiceSelector. Until then, we couldn’t because the ScenePlayer node would consume all mouse input.

To add the ability to advance conversations with clicks, we need to update the project’s input map.

Open the Project -> Project Settings… -> Input Map and add a new entry to ui_accept. You want to add the left mouse button.

And voila! Your game now has both mouse and touch support.

If you add an options menu or any interface that should consume mouse input, you can set its nodes’ mouse filter to Pass and Stop. This will prevent text from advancing.

In our case, it’s the TextBox’s state that controls our game flow. Because we mapped our left mouse button to ui_accept, we leverage our existing input code.

And that is it for this short lesson. In the next one, we’ll add a skip button to make the game’s dialogues fast-forward.

This is a convenient tool for players who want to re-play your game, explore alternate choices, and try to unlock secret events and alternate endings.

Code reference

Here is the complete FadingControl.gd script.

extends Control

const COLOR_WHITE_TRANSPARENT := Color(1.0, 1.0, 1.0, 0.0)

export var appear_duration := 0.3

var _tween := Tween.new()


func _ready() -> void:
    add_child(_tween)
    modulate = COLOR_WHITE_TRANSPARENT


func appear() -> void:
    _tween.interpolate_property(
        self, "modulate", COLOR_WHITE_TRANSPARENT, Color.white, appear_duration
    )
    _tween.start()
    _tween.seek(0)


func disappear() -> void:
    _tween.interpolate_property(
        self, "modulate", Color.white, COLOR_WHITE_TRANSPARENT, appear_duration / 2.0
    )
    _tween.start()
    _tween.seek(0)

And the complete TextBox.gd script.

extends Control

signal display_finished
signal next_requested
signal choice_made(target_id)

export var display_speed := 20.0
export var bbcode_text := "" setget set_bbcode_text

onready var _rich_text_label: RichTextLabel = $RichTextLabel
onready var _name_label: Label = $NameBackground/NameLabel
onready var _blinking_arrow: Control = $RichTextLabel/BlinkingArrow
onready var _tween: Tween = $Tween
onready var _anim_player: AnimationPlayer = $AnimationPlayer
onready var _choice_selector: ChoiceSelector = $ChoiceSelector
onready var _name_background: TextureRect = $NameBackground


func _ready() -> void:
    hide()
    _blinking_arrow.hide()

    _name_label.text = ""
    _rich_text_label.bbcode_text = ""
    _rich_text_label.visible_characters = 0

    _tween.connect("tween_all_completed", self, "_on_Tween_tween_all_completed")
    _choice_selector.connect("choice_made", self, "_on_ChoiceSelector_choice_made")


func _unhandled_input(event: InputEvent) -> void:
    if event.is_action_pressed("ui_accept"):
        if _blinking_arrow.visible:
            emit_signal("next_requested")
        else:
            _tween.seek(INF)


func display(text: String, character_name := "", speed := display_speed) -> void:
    set_bbcode_text(text)

    if speed != display_speed:
        display_speed = speed

    if character_name == ResourceDB.get_narrator().display_name:
        _name_background.hide()
    elif character_name != "":
        _name_background.show()
        if _name_label.text == "":
            _name_label.appear()

        _name_label.text = character_name


func set_bbcode_text(text: String) -> void:
    bbcode_text = text
    if not is_inside_tree():
        yield(self, "ready")

    _blinking_arrow.hide()
    _rich_text_label.bbcode_text = bbcode_text
    call_deferred("_begin_dialogue_display")


func fade_in_async() -> void:
    _anim_player.play("fade_in")
    _anim_player.seek(0.0, true)
    yield(_anim_player, "animation_finished")


func fade_out_async() -> void:
    _anim_player.play("fade_out")
    yield(_anim_player, "animation_finished")


func display_choice(choices: Array) -> void:
    _name_background.hide()
    _rich_text_label.hide()
    _blinking_arrow.hide()
    _name_background.disappear()

    _choice_selector.display(choices)


func _begin_dialogue_display() -> void:
    var character_count := _rich_text_label.get_total_character_count()
    _tween.interpolate_property(
        _rich_text_label, "visible_characters", 0, character_count, character_count / display_speed
    )
    _tween.start()


func _on_Tween_tween_all_completed() -> void:
    emit_signal("display_finished")
    _blinking_arrow.show()


func _on_ChoiceSelector_choice_made(target_id: int) -> void:
    emit_signal("choice_made", target_id)
    _name_background.show()
    _rich_text_label.show()
    _name_background.appear()