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.
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.
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.
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()