In Part 1, we set up our project and scripted a basic conversation with branching options.
In Part 2, we added character swapping, text formatting using BBCode, and handling variables within the conversation.
This part is going to focus on extending our script with a couple of extra enhancements: triggering arbitrary events during the conversation, and also handling longer sections of dialogue by scrolling the text.
Afterwards, we’re also going to look at how to integrate our conversation node into a larger game scene.
There are two features we’re going to implement in this section:
What I mean by the first point is something like this:
"dialogue": "Hi. Um...<pause 2.0> who are you?"
The section between the angle brackets <>
is a function call. It won’t print out, but it will call a pause
function that stops text printing for 2.0 seconds.
You could use the same concept to, for example, trigger an animation. Something like this:
"dialogue": "Let's go!<move player left 10.0>"
The second point, scrolling the dialogue, may sound trivial, but it’s deceptively complex. The RichTextLabel
node doesn’t give us good information about whether or not line-wrapping is occurring, how many lines our text actually uses, whether overflow is occurring, etc.
To give a brief overview of a couple of useful functions the RichTextLabel node does give us:
get_total_character_count()
- Gives us the total number of characters in the text. Does not include BBCodes.scroll_to_line(int line)
- Allows us to scroll the top of the window to match line
. Wrapped text counts as one line.In a nutshell, our strategy for correctly scrolling our text is going to be:
characters_per_line
variable. For me, this is 21. This only works if you’re using a monospaced font (which we are). Otherwise, your characters per line will be variable and you won’t be able to use this method.max_visible_lines
variable. For me, this is two. If we go over this, it means we need to start scrolling.characters_per_line
and then insert a newline (\n
) appropriately (ensuring scroll_to_line
will work for us)<scroll>
after these newlines where vertical overflow will occur. Our scroll
method will use scroll_to_line
to increment the scroll position by one.So, both of these are going to depend on us being able to call functions during a piece of dialogue. Let’s modify our first piece of dialogue in the conversation so that it uses both of these features.
"dialogue": "Hi, I'm ALEX. <pause 0.5>.<pause 0.5>.<pause 0.5>. [color=yellow]It's nice to meet you![/color] Uh...<pause 2.0> How should I refer to you?",
You can see that I’ve included several calls to a pause
function which we’ll need to define. I’ve also included a bbcode tag to ensure the combination doesn’t interfere. I’ve also made the line longer so that it will need to wrap and scroll.
First, let’s figure out how we can interpret these inline function call tags within the <>
brackets.
For the string parsing, we’re going to use a Regular Expression (GDScript class RegEx).
Define a new variable,
var inline_function_regex = RegEx.new()
and in _ready
,
inline_function_regex.compile("<(?<function_call>.+?)>")
Our regex string, <(?<function_call>.+?)>
, uses a Named Capture Group to extract everything between <>
brackets. You’ll see how we’ll use it in a moment.
Note: there’s no real reason why you can’t use something other than <>
as an identifier - it’s just convenient because it doesn’t clash with the format brackets {}
or the bbcode brackets []
.
Tip: https://regex101.com/ is a great tool for learning how a particular expression works. I’m by no means an expert at RegEx and I used this tool to come up with the expression used here.
The idea is that we’ll pre-process our string, save the function calls we need to make, remembering the index we need to call them at, and then remove them from the string.
To store the function calls, let’s define a variable.
var dialogue_calls = {}
Define a helper function which we’ll use for adding function calls to our dictionary in the required format.
func add_dialogue_call(index, dialogue_call):
var dialogue_calls_at_index = dialogue_calls.get(index, [])
dialogue_calls_at_index.push_back(dialogue_call)
dialogue_calls[index] = dialogue_calls_at_index
We store the functions by their index for easy lookup when we’re printing out the text. It’s possible to have multiple function calls at the same location, so each value is actually an array of function calls that we’ll need to iterate through.
We expect dialogue_call
to be an array of strings containing each argument between the <>
brackets (you’ll see why in a minute).
For example, if we had dialogue like this,
"Hi <pause 2.0><animate player talk>, <animate player wave>"
we’d expect this in our dialogue_calls
dictionary,
{
3: [
["pause", "2.0"],
["animate", "player", "talk"]
],
5: [
["animate", "player", "wave"]
]
}
Next, define a new function, process_dialogue_inline_functions
,
func process_dialogue_inline_functions(dialogue):
# Clear our existing function call list
dialogue_calls = {}
var visible_character_count = 0
var in_bbcode = false
var i = -1
while i + 1 < dialogue.length():
i += 1
var character = dialogue[i]
# Ignore bbcode tag sections
if character == '[':
in_bbcode = true
continue
elif character == ']':
in_bbcode = false
continue
elif in_bbcode:
continue
# If this is the start of an inline function call, process it and strip it from the dialogue
if character == '<':
var result = inline_function_regex.search(dialogue, i)
if result:
add_dialogue_call(visible_character_count, result.get_string("function_call").split(" "))
dialogue.erase(result.get_start(), result.get_end() - result.get_start())
i -= 1
else:
visible_character_count += 1
return dialogue
Now, this function might look a little complex, but there’s really only a couple of things it’s doing.
It iterates through our dialogue string, character by character, ignoring any bbcode tags, and checks for our special character '<'
.
When it finds a '<'
it uses the regex to search from the current position, extracts the function_call
capture group, splits it into an array, and then passes it to add_dialogue_call
. Having saved that function, it then erases it from the dialogue string.
While iterating through the dialogue, we keep track of the number of visible characters since that’s what we iterate through when printing. It gives us the correct index at which to perform the function.
Finally, we return the modified dialogue
with the function calls removed.
Also note that we use a while
loop instead of a for
loop. This is because the length of the dialogue can change during the loop (from the call to erase
) and also we need to modify i
when erasing a function call. It’s therefore simpler to use a while
loop and manage the iterator variable i
ourselves.
Finally, we need to call this function at the start of print_dialogue
. We want to do it after we’ve formatted the dialogue, but before we assign it to bbcode_text
.
In print_dialogue
, replace the line where we assign bbcode_text
with this:
var formatted_dialogue = dialogue.format({ "title": title })
dialogue_node.bbcode_text = process_dialogue_inline_functions(formatted_dialogue)
Our inline functions won’t be getting called yet, but you can still test this out from here.
If you run the scene and start the conversation, you should see that the function calls have been removed from the dialogue.
Next, let’s look at how we can actually call these functions while the dialogue is printing.
Let’s first define our pause
function.
func pause(delay_string):
var delay = float(delay_string)
yield(get_tree().create_timer(delay), "timeout")
Pretty straightforward. All our arguments for these functions are going to be passed in as strings, so they’ll need to be cast first. So, we cast the string to a float, then we create a one-shot timer and yield
.
Now let’s call our functions from print_dialogue
.
Add this function to help us do that,
func call_dialogue_functions(index):
var dialogue_calls_for_index = dialogue_calls.get(index)
var results = []
if dialogue_calls_for_index:
for dialogue_call in dialogue_calls_for_index:
var call_method = dialogue_call[0]
dialogue_call.remove(0)
results.push_back(callv(call_method, dialogue_call))
return results
For the given index, get the array of dialogue calls to make, then iterate through, returning the results of each as an array. The first element of the array is the method name, and the remainder are the arguments.
Call the function from print_dialogue
, before we start the timer or increment visible_characters
,
for i in dialogue_node.get_total_character_count():
# Process any function calls first, before showing the next character.
var results = call_dialogue_functions(i)
if results:
for result in results:
# This is what the function will return if it yields.
# If it does yield, we also need to yield here to wait until that coroutine completes.
if result is GDScriptFunctionState:
yield(result, "completed")
In addition to calling call_dialogue_functions
we also process the results here. In particular, we need to handle the case of when these functions yield
. If they do, then we need to yield
here too or else the pause
will have no effect - it will run in a separate coroutine and not block our printing function.
That should be it! Try it out. When you run the scene, you should see that the dialogue pauses printing for the specified duration at the locations where we inserted those <pause>
commands.
Ok, let’s talk about scrolling next. There’s a couple of things we need to do here. First, detect when we need to scroll. Second, perform the scroll at the correct time.
As mentioned earlier, the RichTextLabel
node isn’t good at telling us when text overflows horizontally and wraps around. Therefore, we’re going to implement our own text wrapping. Given we’re using a font where characters have a fixed width, this is relatively simple. We can define a constant number of characters per line, and then insert newlines anywhere a word would take a line over that number. We’ll want to be a little strategic about where we place those newlines as well, so that we don’t break any words in half.
To do this, I’m going to expand our call to .format
and define a new function, format_dialogue
, that will handle both the .format
and insertion of any required newlines.
First, there’s a couple of variables we’ll need:
export var characters_per_line = 21
export var max_lines_visible = 2
This is based on my own layout - yours might be different. Basically, this defines the size of the area we have that can display dialogue. Mine is 21 characters wide and 2 lines high.
Note, you might need to increase the width of your dialogue box for this section. We want to actively avoiding lines wrapping by hitting the side of the dialogue box, and instead use our own newlines.
Next, define our new function,
func format_dialogue(dialogue):
# Replace any variables in {} brackets with their values
var formatted_dialogue = dialogue.format(dialogue_variables())
var characters_in_line_count = 0
var line_count = 1
var last_space_index = 0
var ignore_stack = []
# Everything between these brackets should be ignored.
# It's formatted in a dictionary so we can easily fetch the corresponding close bracket for an open bracket.
var ignore_bracket_pairs = {
"[": "]",
"<": ">"
}
for i in formatted_dialogue.length():
var character = formatted_dialogue[i]
# Ignore everything between [] or <> brackets.
# By using a stack, we can more easily support nested brackets, like [<>] or <[]>
if character in ignore_bracket_pairs.keys():
ignore_stack.push_back(character)
continue
elif character == ignore_bracket_pairs.get(ignore_stack.back()):
ignore_stack.pop_back()
continue
elif not ignore_stack.empty():
continue
# Keep track of the last space we encounter.
# This will be where we want to insert a newline if the line overflows.
if character == " ":
last_space_index = i
# If we've encountered a newline that's been manually inserted into the string, reset our counter
if character == "\n":
characters_in_line_count = 0
elif characters_in_line_count > characters_per_line:
# This character has caused on overflow and we need to insert a newline.
# Insert it at the position of the last space so that we don't break up the work.
formatted_dialogue[last_space_index] = "\n"
# Since this word will now wrap, we'll be starting part way through the next line.
# Our new character count is going to be the amount of characters between the current position
# and the last space (where we inserted the newline)
characters_in_line_count = i - last_space_index
characters_in_line_count += 1
return formatted_dialogue
func dialogue_variables():
return {
"title": title
}
I’ve included a few comments in this function to give more specific explanations for some of the lines. The overview is, we’re iterating through the dialogue string and counting the characters, ignoring bbcode tags []
and function call brackets <>
. If the count goes over the maximum, we insert a newline at the start of the current word. If we encounter an existing newline (for example, if we’ve manually included one in our dialogue string), we reset our counter and start the new line.
I’ve also pulled dialogue_variables()
out into a separate function so that it isn’t buried inside format_dialogue
.
Now all that’s left to do is call our new function from print_dialogue
. Replace the call to .format
with this line,
var formatted_dialogue = format_dialogue(dialogue)
That’s really all we need to do. Our dialogue should now have newlines inserted at appropriate places so we don’t need to rely on the RichTextLabel
to wrap our text any more.
The final thing we need to handle is scrolling. At the moment, if you play the scene, the dialogue will continue past two lines but the label won’t scroll to keep up and the dialogue won’t be visible.
As mentioned earlier, we’re going to utilise our new inline function call feature to enable this by inserting a "scroll"
function call at the same locations as our newlines.
First, we need to ensure that the scrollbar is disabled on our Dialogue
node.
Now, let’s define our scroll
function. We also need a variable to keep track of our current scroll position (since RichTextLabel
doesn’t give it to us).
var current_scroll_position = 0
func scroll():
current_scroll_position += 1
dialogue_node.scroll_to_line(current_scroll_position)
Next, we need to insert some calls to scroll
into our dialogue_calls
dictionary at appropriate positions. Modify process_dialogue_inline_functions
so that it looks like this,
func process_dialogue_inline_functions(dialogue):
# Clear our existing function call list
dialogue_calls = {}
var visible_character_count = 0
var line_count = 1
var in_bbcode = false
var i = -1
while i + 1 < dialogue.length():
i += 1
var character = dialogue[i]
# Ignore bbcode tag sections
if character == '[':
in_bbcode = true
continue
elif character == ']':
in_bbcode = false
continue
elif in_bbcode:
continue
# If this is the start of an inline function call, process it and strip it from the dialogue
if character == '<':
var result = inline_function_regex.search(dialogue, i)
if result:
add_dialogue_call(visible_character_count, result.get_string("function_call").split(" "))
dialogue.erase(result.get_start(), result.get_end() - result.get_start())
i -= 1
# Perform manual scrolling.
# If this is a newline and we're above the maximum number of visible lines, insert a 'scroll' function
elif character == "\n":
line_count += 1
if line_count > max_lines_visible:
add_dialogue_call(visible_character_count, ["scroll"])
else:
visible_character_count += 1
return dialogue
You can see that we’ve added a line_count
variable and included an extra elif
clause for detecting newlines. The logic is fairly simple. If we detect a newline and the current line count is greater than the maximum number of lines we can show, insert a call to scroll
at that location.
Ok, we’re ready to try it out! If you run the scene and start the conversation, you should now see that the dialogue scrolls down to the next line when it reaches the end of the last visible line.
You might notice one issue though, if you have muliple pieces of dialogue in a row that require scrolling, the scroll position won’t reset.
It’s an easy fix - all we need to do is reset the scroll position when we start printing some new dialogue. Near the start of print_dialogue
, where we set visible_characters
to 0, edit your code to look like this,
dialogue_node.visible_characters = 0
dialogue_node.scroll_to_line(0)
current_scroll_position = 0
And that should be all we need to fix the issue.
Ok, you may have noticed one more, much more serious issue. Hitting Enter to skip to the end of the text now has a couple of problems.
While it would be possible to skip over a chunk of text and play out all the functions we need to immediately, it would be very complicated, and not something I really feel like doing given what would be a fairly small pay-off.
So, instead of fixing our now-broken feature, I’m going to solve the problem in a different way.
The problem statement is: players who have read the text before (or who are just fast readers) want to move through the dialogue as quickly as possible.
Notice I haven’t defined the problem as ‘skip the dialogue completely’. This would be more like skipping an entire cutscene. It might be something your game needs, but it’s not what I was intending for this feature.
So, let’s look at the problem statement. In particular, where I said “as quickly as possible”. Well, we have a limitation with our current implementation that we really only want to go one character at a time to ensure we don’t miss any function calls. So, let’s say “as quickly as possible” means “one character per frame” for our purposes. This means that, when the player presses and holds Enter to skip, we just want to show one character per frame, rather than waiting for the text timer.
The first thing I’m going to do is rename the skip_text_printing
variable to fast_text_printing
, so that we declare the variable like this.
var fast_text_printing = false
At this point, you should also rename all the other references to this variable in the file.
Next, I’m going to replace the skip_test_printing()
function with these two functions,
func start_fast_text_printing():
fast_text_printing = true
text_timer_node.emit_signal("timeout")
func stop_fast_text_printing():
fast_text_printing = false
Instead of this being a single event that occurs when a button is pressed, it will be something that continues while the button is held down. That means we need both a ‘start’ and a ‘stop’ event.
Inside our _process
function, change the code block that previously started with if skip_text_printing:
to this,
if text_in_progress:
if Input.is_action_just_pressed("ui_accept"):
start_fast_text_printing()
elif Input.is_action_just_released("ui_accept"):
stop_fast_text_printing()
return
Now these functions will get called on the press and release of the Enter key.
Finally, we need to change the logic in our print_dialogue
function. At the end of the loop where we start the timer and show the next character, replace the existing code with this,
if fast_text_printing:
dialogue_node.visible_characters += 1
yield(get_tree(),"idle_frame")
else:
text_timer_node.start()
dialogue_node.visible_characters += 1
yield(text_timer_node, "timeout")
What we’re doing here is, if we’re doing the fast text printing, instead of waiting for the timer we just wait for the next frame. This is pretty much as fast as we can go if we’re only going one character at a time.
That’s it! We’re ready to try it out now. If you run the scene and start the conversation, you should be able to speed up the text by holding down the Enter key!
You’ll notice that the pauses still take effect, which would be kind of annoying as a player if your intention was to skip forward as quickly as possible. Let’s look next at how we can selectively skip over some of our inline functions.
Within the loop inside print_dialogue
, we’re going to expand our inline function-handling code a bit.
var results = call_dialogue_functions(i)
if results:
for result in results:
# This is what the function will return if it yields.
if result is GDScriptFunctionState:
# If we're trying to skip quickly through the text, complete this function immediately
if fast_text_printing:
while result is GDScriptFunctionState and result.is_valid():
result = result.resume()
else:
# Otherwise, if the function has yielded, we need to yield here also to wait until that coroutine completes.
yield(result, "completed")
We now have a conditional statement here checking our fast_text_printing
variable. If the value is true, instead of yielding we call resume()
on it. We do this multiple times in case the function yields multiple times (shown in an example in the GDScript docs https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_basics.html#coroutines-with-yield).
This should work with what we’ve currently got. Try running the conversation. You should be able to skip quickly through the pauses just like with the other text.
The only thing we want to be careful of is if we have function calls that we shouldn’t skip. For example, an animation that you want to complete before continuing.
If we want to do this selectively, we need the results returned from call_dialogue_functions
to have a little more information. Let’s modify that function so that it also returns the name of the function the result is for.
In call_dialogue_functions
, where we’re pushing the result onto our results array, change the line to this,
results.push_back([call_method, callv(call_method, dialogue_call)])
See how we’re now including the name of the method being called.
Let’s also define a list of functions that are safe for us to skip.
var skippable_functions = ["pause"]
Now, let’s modify our code in print_dialogue
one more time to handle this new data structure.
var results = call_dialogue_functions(i)
if results:
for result in results:
if not result:
continue
var dialogue_function = result[0]
var result_value = result[1]
# This is what the function will return if it yields.
if result_value is GDScriptFunctionState:
# If we're trying to skip quickly through the text, complete this function immediately if we're allowed to do so.
if fast_text_printing and dialogue_function in skippable_functions:
while result_value is GDScriptFunctionState and result_value.is_valid():
result_value = result_value.resume()
else:
# Otherwise, if the function has yielded, we need to yield here also to wait until that coroutine completes.
yield(result_value, "completed")
That should now put the final touches on that feature. We can now move quickly through the text and still prevent certain important functions from being skipped over.
Up until now we’ve been solely focussed on our dialogue scene. We’re going to take a bit of a detour here and start thinking about how we can fit this into a larger project.
Let’s imagine that this conversation is part of a cutscene in a 2D platformer. I’m going to create a new scene and, using some of the asset packs I’ve downloaded, I’m going to build a little scene with a couple of characters.
Here are the asset packs I’ve used:
I’m not going to give you my exact scene layout, so just have fun with this and build something cool! All you’ll need is a node to represent your player, and another to represent an NPC (who we’ll be having the conversation with).
Keep in mind how the scene is going to look with our dialogue UI at the bottom. Ensure the scene will still look ok with the bottom 64 pixels covered.
Here’s what I’ve come up with.
What you’ll want to do now is drag in our DialogueUI scene. Position it at (0,0) so that it lines up with the bottom of the screen.
And you can see the basic structure of my scene here.
Basically, I’ve got a background and foreground layer, which contain all the static sprites for the environment. Then, I’ve got two AnimatedSprites
to represent my player and my NPC. Finally, our DialogueUI
node (which I’ve just name UI
) sits at the bottom.
Now, what I want to do is to be able to walk my character up to the NPC and, when I get close enough, trigger the conversation from a button press.
This tutorial isn’t about 2D character control so I’m just going to write a really basic script.
Add a new script to your Player node and add this code.
extends AnimatedSprite
export var walk_speed = 40
var in_conversation = false
func _process(delta):
if in_conversation:
return
if Input.is_action_pressed("ui_right"):
translate(Vector2(walk_speed * delta,0))
play("walk")
flip_h = false
elif Input.is_action_pressed("ui_left"):
translate(Vector2(-walk_speed * delta,0))
play("walk")
flip_h = true
else:
play("idle")
I defined an "idle"
and a "walk"
animation on my sprite, so I’m playing the "walk"
animation when my character is moving, and "idle"
otherwise. Feel free to remove these lines if you haven’t bothered to animate your character.
I’ve also anticipated that we’ll want to prevent player movement while a conversation is active, so I’ve set up an in_conversation
variable in preparation for that.
Now, we can move our character to the left and right, but what we want is for our dialogue UI to be hidden initially, and then appear and start the conversation when we get close to the NPC. There’s some tweaks we’ll need to make to our DialogueUI script so that we can start conversations from a script.
Change the _ready
function to look like this,
func _ready():
visible = false
inline_function_regex.compile("<(?<function_call>.+?)>")
and add a new function, start_conversation()
,
func start_conversation():
current_index = 0
current_choice = 0
print_dialogue(conversation[current_index]["dialogue"])
visible = true
This means that our conversation won’t start automatically when we run our scene. Instead, it will wait for start_conversation()
to get called. When start_conversation()
is called, we set up the variables for our conversation, start the first piece of dialogue, and make our UI visible.
We need to add one more thing at the start of _process
.
func _process(delta):
if not visible:
return
This prevents button presses from advancing the conversation until we’ve actually started it.
You won’t see much yet if you run the scene. The dialogue UI will be hidden, but there’s nothing to trigger the start of the conversation.
Let’s add some code to our player script, so that it can trigger the conversation.
export var ui_path: NodePath
onready var ui = get_node(ui_path)
func _process(delta):
if not in_conversation:
ui.start_conversation()
in_conversation = true
Now, assign the ui_path
variable to the UI node in the scene.
If you run the scene now, you should again see that the conversation starts immediately.
Now that we know our start_conversation
function works you can remove the code we just added to the top of _process
. We’re not going to need it.
Let’s add a script to our NPC. The NPC is going to let the player know when it’s within range, so the player knows when it can start a conversation.
Here’s my NPC script,
extends AnimatedSprite
export var player_path: NodePath
onready var player = get_node(player_path)
export var conversation_distance = 30
func _ready():
$SpeechBubble.visible = false
func _process(delta):
if abs(position.x - player.position.x) < conversation_distance:
$SpeechBubble.visible = true
player.npc_in_range()
else:
$SpeechBubble.visible = false
Notice my NPC includes a node called SpeechBubble
. I’ve added this as a visual indicator for when the player is able to talk to the NPC. Feel free to add this in as well if you like.
This script also has a reference to the player (don’t forget to assign it in the scene view). If the distance between the NPC and the player is less than conversation_distance
, the speech bubble indicator will show, and the NPC calls a function on the player, npc_in_range
, to let it know that they can start a conversation.
Let’s define npc_in_range
on our player now.
func npc_in_range():
if not in_conversation and Input.is_action_just_pressed("ui_accept"):
in_conversation = true
ui.start_conversation()
We’ll use the Enter key ("ui_accept"
) to trigger our conversation. A conversation can only be triggered if we aren’t already in a conversation.
So, essentially this function is triggered when our NPC is within range of the player, and if they are, we’ll start a conversation when Enter is pressed, as long as a conversation isn’t already in progress.
Run the scene to test out this code. You should be able to walk up to the NPC and press Enter to start the conversation.
The next thing we need to consider is how to end the conversation. At the moment, we just get to the final piece of dialogue and end up stuck there. Let’s fix that.
To allow the player to get notified at the end of the conversation, we’re going to use a Custom signal.
Add this to your Dialogue UI script:
signal conversation_finished
Next, define a finish_conversation
function.
func finish_conversation():
visible = false
emit_signal("conversation_finished")
Finally, add an else
clause at the end of the _process
function to call it at the end of the conversation.
else:
if Input.is_action_just_pressed("ui_accept"):
finish_conversation()
That else
block should match up with if current_index < (conversation.size() - 1)
. If we’re inside that else
block, it means we’re at the end of the conversation.
If the player presses Enter when at the end of the conversation, we’re going to call our new function finish_conversation
, which hides the UI and emits our new signal.
Next, with the UI selected in the scene view, select to Signals tab and connect finish_conversation
to the Player node.
All we need to do in our Player script is to set in_conversation
to false
when this signal is emitted.
func _on_UI_conversation_finished():
in_conversation = false
That’s it! Test out your scene again. You should be able to run through the whole conversation with the NPC and then continue playing. The UI should hide once you’re done, and then you should be able to trigger the conversation again and repeat it.
In this section we looked at how to trigger events during a conversation, and how to incorporate our script into a larger scene.