In this part, we will write the code to show text, animate the text display, and jump to the end of the display and request the next reply.
Right now, we only have the text box scene, so we will write our code in such a way it doesn’t rely on classes we’ll write in upcoming lessons. Working this way will allow us to test the text box and it generally makes it easier to reuse components across game projects.
Add a script to the TextBox node and let’s start with the usual signals and properties.
Most notably, we provide a signal to indicate other systems that the player requested the next line of text. As it’s the scene player that’ll provide text for the box to display, we’ll need a the signal for the two to communicate.
## Displays character replies in a dialogue extends Control ## Emitted when the next line was requested. The scene player will catch the ## signal and use it to move to the next step in the scene, whether it's another ## line of dialogue, an animation, or other. signal next_requested ## Speed at which the characters appear in the text body in characters per ## second. export var display_speed := 20.0 ## Controls the BBCode text of the rich text label child node. ## Notice we go through a setter to do so. We'll write its code in a moment. export var bbcode_text := "" setget set_bbcode_text # We store references to the nodes we need to display text and to handle the # player's input. onready var _rich_text_label: RichTextLabel = $RichTextLabel onready var _name_label: Label = $NameBackground/NameLabel onready var _blinking_arrow: Control = $RichTextLabel/BlinkingArrow
The next step is to reset the nodes’ state in the _ready()
function.
There, we can make nodes invisible by default and reset the RichTextLabel node’s bbcode_text
.
It allows us to have some placeholder text in the TextBox scene to preview the font and bounding boxes without risking issues at runtime.
By default, we want the TextBox node to be invisible, as well as the BlinkingArrow, and we want no text in the RichTextLabel and NameLabel.
func _ready() -> void: hide() _blinking_arrow.hide() _name_label.text = "" _rich_text_label.bbcode_text = "" _rich_text_label.visible_characters = 0
Next are the two main functions that display the text: display()
and set_bbcode_text()
.
func display(text: String, character_name := "", speed := display_speed) -> void: # We use the setter to update the text in the RichTextLabel node. set_bbcode_text(text) if speed != display_speed: display_speed = speed if character_name != "": _name_label.text = character_name # On top of updating the RichTextLabel's text, we hide the arrow. # We'll rely on the arrow's visibility to code the player input. 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
display()
is the main function the scene player or an external system should call to display a line of text. We provide parameters to control the displayed character name and the animation speed.
That way, you can have some fast replies and some that display a bit slower.
Ideally, we may want to have a way to animate the display speed within a reply. But this is beyond the scope of the series, as it would involve parsing extra information within the text string itself.
Our code looks a bit complicated if it’s just to display text, but here’s where it becomes interesting.
We want to animate the characters appearing based on our display_speed
. To do so, we’ll animate the RichTextLabel.visible_characters
using a Tween
node.
In the scene, add a new Tween
node as a child of TextBox
.
We first get a reference to the node in our script and connect to its tween_all_completed
signal. We’ll use a new signal to indicate the text box finished displaying text.
## Emitted when the text animation display finished, even if the player pressed enter to fast-forward it. ## We can use this for other nodes in the game to fast-forward their animation as well. signal display_finished onready var _tween: Tween = $Tween func _ready() -> void: #... _tween.connect("tween_all_completed", self, "_on_Tween_tween_all_completed") func _on_Tween_tween_all_completed() -> void: emit_signal("display_finished") # When all the text is visible, we show the arrow to indicate we can move to the next text reply. _blinking_arrow.show()
Let’s now put the tween node to use. We add a new _begin_dialogue_display()
function that starts the animation and update set_bbcode_text()
to use it.
func set_bbcode_text(text: String) -> void: #... # Required for the RichTextLabel's text to update and the code below to work. call_deferred("_begin_dialogue_display") # This function calculates the duration of the text's animation, that shows # characters one by one based on our desired display speed. 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()
It can take up to a frame for RichTextLabel
to recalculate its total character count, so we use call_deferred()
to delay the function call and ensure our text will display correctly. If we called the function directly, RichTextLabel.get_total_character_count()
would return 0
, causing the text to never appear.
Game engines can delay some processes until the end of the current frame, or even spread them over time. When you modify the BBCode of a RichTextLabel
, because parsing it can be a bit long, the engine doesn’t process it instantly.
In those cases, in Godot, call_deferred()
is your friend. Another technique is to use yield(get_tree(), "idle_frame")
to explicitly wait for the next frame, something you may need when saving a screen capture.
There are cases where the GDScript compiler will warn you that you should call call_deferred()
to use a given method, but sometimes, as with RichTextLabel
, there’s no warning.
At this point, you can already test your code. In _ready()
, show the text box and call display()
.
func _ready() -> void: #... show() # Displays the text at a speed of `10` character per second. display("Hello, world!", "Narrator", 10)
Run the scene with F6 to see the text display, slowly.
We’re still lacking player input. We’ll add and test it now.
When the player presses enter, two things can happen:
func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_accept"): if _blinking_arrow.visible: emit_signal("next_requested") else: # When the player presses enter and the text is currently animating, we want to # jump to the end of the animation and display the blinking arrow. # To do so, we seek to the tween's end. # The value `INF` stands for infinity and makes it always seek to the end. _tween.seek(INF)
Notice how instead of defining a boolean for when pressing Enter should advance the text, we use the blinking arrow’s visibility.
Relying on an existing node property to check for the scene’s current state is something we generally recommend. The more variables you add, the more chances you have to introduce bugs, as most bugs stem from mutating state.
With that, you can press Enter to jump to the end of a text animation. We can also test chaining text replies in our _ready()
function, like so.
func _ready() -> void: #... display("Hello, world!", "Narrator", 10) yield(self, "next_requested") display("What should we say next?", "Narrator", 25) yield(self, "next_requested") _rich_text_label.bbcode_text = "" _blinking_arrow.hide()
This is temporary code, so you can remove it all after testing everything works fine.
The text box won’t load conversations and get data by itself. As we’ll need to sequence many things in the game, like character animations or fading parts of scenes, we want to let some scene director control the text box’s animation, too.
We’ll add two more functions to play the fade animations we designed in the previous lesson.
onready var _anim_player: AnimationPlayer = $AnimationPlayer # The following functions allow the scene player to play fade animations on the # text box. func fade_in_async() -> void: _anim_player.play("fade_in") # During development, I found that sometimes animations would not update the # scene instantly, causing a small visual glitch. Calling the # AnimationPlayer.seek() function with two arguments forces it to update the # nodes' properties instantly. _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")
The way these functions are, another node can play an animation but not stop it. We also turn the functions into coroutines using the yield()
keyword, allowing other nodes to wait for the function call to complete.
It’s an information we indicate to teammates by adding the _async()
suffix to function names.
If you’re wondering why only fade_in_async()
has that call to AnimationPlayer.seek()
, it’s because it’s the only animation that caused that visual artifact. And I think it’s because at the start of the game, the nodes have an opaque white Modulate color, and the animation makes them transparent.
In some cases, it seems it can take one frame for the animation to trigger after starting it, leaving the text box visible for a split second before making it transparent.
To test the functions, once again, you can sequence temporary calls in _ready()
.
func _ready() -> void: #... yield(fade_in_async(), "completed") display("Hello!", "Sophia")
You can remove that temporary code after testing everything works fine.
Our text box has everything we need. Next, we’ll work on the characters.
Here’s the complete TextBox.gd
script.
extends Control signal display_finished signal next_requested 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 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") 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 != "": _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 _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()