In Part 1, we looked at:
In Part 2, we’re going to get a lot deeper into scripting.
We’ll be making the conversation more dynamic, adding the ability to swap characters and insert variables into the text.
We’re also going to add some animation and enhanced visuals to our text by printing out characters and using BBCode.
Continuing on from Part 1, what you should have is a scene where the player can have a conversation with a character and make choices to take different branches.
This is all great if your player character only ever has a conversation with one person. Realistically however, you’ll want to be able to change which character to use per conversation, and also have conversations with multiple participants.
Let’s get started by making our characters configurable on the node.
Export a couple of variables for the character name and portrait.
export var character_name = "ALEX"
export var character_portrait: Texture
We also need to let our script know about the Portrait node.
onready var portrait_node = get_node("PortraitRect/Portrait")
Now, let’s set the name and portrait in our _ready
function.
portrait_node.texture = character_portrait
name_node.text = character_name
That’s it! Go back to our scene and try setting a new character name on our node and dragging in a new portrait texture.
Run the scene. Easy as that, a configurable character name and portrait!
But what if we want different characters as part of the conversation? Well, let’s start with how we think we’d define that in our conversation data structure.
var conversation = [
{
"character": "alex",
"dialogue": "Hey there.\nDo you like apples?",
"choices": [
{
"dialogue": "Sure do!",
"destination": "apples_good"
},
{
"dialogue": "No way, gross!",
"destination": "apples_bad"
}
]
},
{
"character": "alex",
"label": "apples_good",
"dialogue": "You like apples? Me too!",
"destination": "part_2"
},
{
"character": "alex",
"label": "apples_bad",
"dialogue": "You don't?\nThat's a shame."
},
{
"label": "part_2",
"character": "alex",
"dialogue": "I like other fruits too."
},
{
"character": "alex",
"dialogue": "Hey JUPITER, what do you like?"
},
{
"character": "jupiter",
"dialogue": "I prefer oranges..."
},
{
"character": "alex",
"dialogue": "Bananas are my favourite!"
}
]
We’ve added in a "character"
key for each section of dialogue with the character’s name. Notice we haven’t specified the portrait texture here as well. It’ll be easier if we just use this character name as a label to look up the portrait texture and the display name.
Let’s modify our exported variables to accomodate that. (Replace the image filepaths with your own portraits that you’re using if needed.)
export var characters = {
"alex": {
"name": "ALEX",
"portrait": preload("res://images/Pixel Portraits/female_10_t.png")
},
"jupiter": {
"name": "JUPITER",
"portrait": preload("res://images/Pixel Portraits/female_11_t.png")
}
}
It should now look like this in the editor:
Next, let’s define a function that will let us fetch these names and portrait textures based on the current character in the conversation.
func update_character():
var current_character = conversation[current_index].get("character")
portrait_node.texture = characters[current_character]["portrait"]
name_node.text = characters[current_character]["name"]
Finally, let’s call this function whenever the conversation progresses.
In _ready
,
func _ready():
update_text_labels()
update_select_indicators()
update_character()
and in _process
,
if current_index != previous_index:
update_text_labels()
update_character()
reset_selection()
That’s all we need to do! Try running the scene. You’ll see the character names and portraits change based on the character labels we provided in our conversation data.
Currently, we really only have one state our conversation can be in, which is to display the current dialogue and available choices. If we want to show the text printing out before we make a choice, we need to introduce a second state. We can do this by simply introducing a boolean variable, text_in_progress
, and having our _process
function do different things based on its value.
text_in_progress
is true
, we’re in the process of printing out text. Our current choices should be hidden.text_in_progress
is false
, our full text is displayed and the current choices are shown.We progress from 1) to 2) automatically when the text is complete.
We progress from 2) back to 1) (incrementing the dialogue index) when the player makes a choice.
To control the printing animation of the text, we’re going to use a Timer node.
I’ve based this section on an existing example here https://www.codegrepper.com/code-examples/go/TypeWriter+Text+Godot
Add a Timer
node to scene, called TextTimer
.
Text speed can be modified by changing the Wait time of the Timer.
Make the timer node available in our script.
onready var text_timer_node = get_node("TextTimer")
Add in a variable to represent the new state we need.
var text_in_progress = false
Define some functions to help us show/hide the choices between state transitions. You’ll see how we use these below.
func show_choices():
set_choices_visible(true)
reset_selection()
choice_a_node.text = get_current_choice(0).get("dialogue", "...")
choice_b_node.text = get_current_choice(1).get("dialogue", "")
func hide_choices():
set_choices_visible(false)
func set_choices_visible(visible):
var nodes = [
select_a_node,
select_b_node,
choice_a_node,
choice_b_node
]
for node in nodes:
node.visible = visible
Next, add this function. It will handle the printing of the dialogue one character at a time.
func print_dialogue( dialogue ):
text_in_progress = true
update_character()
hide_choices()
dialogue_node.text = ""
for letter in dialogue:
text_timer_node.start()
dialogue_node.add_text(letter)
yield(text_timer_node, "timeout")
show_choices()
text_in_progress = false
Our transition into the ‘printing’ state happens at the start of this function. Within the loop, we add a letter and use yield
to allow it to display and wait for the timer before showing the next one. Once the loop is done and all our text is printed out, we show the choices and toggle our state.
In _process
, start with,
if text_in_progress:
return
This prevents user action during the printing state.
To transition to the next piece of dialogue, we now simply call print_dialogue
after a choice is made. Like this,
if current_index != previous_index:
print_dialogue(conversation[current_index]["dialogue"])
All the required updates are now handled with print_dialogue
.
Finally, in _ready
, we just need to kick off the initial bit of dialogue,
func _ready():
print_dialogue(conversation[current_index]["dialogue"])
We no longer need the update_text_labels
function. It can be removed.
You can now run the scene!
There’s one more feature here I’d like to add - press Enter to skip to the end (show all dialogue and choices).
Define a boolean variable to flag when we should skip the rest of the printing of the text:
var skip_text_printing = false
During _process
, if we’re currently printing out text and Enter is pressed, then skip the text printing:
func _process(delta):
if text_in_progress:
if Input.is_action_just_pressed("ui_accept"):
skip_text_printing()
return
Define skip_text_printing()
. This is going to set our variable and stop our text timer.
func skip_text_printing():
skip_text_printing = true
text_timer_node.emit_signal("timeout")
text_timer_node.stop()
We also force the timer to fire immediately, so that we don’t wave to wait when using slow text speeds.
Then, to skip the rest of the text inside print_dialogue()
, put this after the call to yield
,
if skip_text_printing:
skip_text_printing = false
dialogue_node.text = dialogue
break
This will immediately complete the text and break out of our letter printing loop the next time our timer fires.
Run the scene. You should be able to skip through the conversation by pressing the Enter key to skip the letter-by-letter printing.
Here’s where your script should be at:
extends Control
export var characters = {
"alex": {
"name": "ALEX",
"portrait": preload("res://images/Pixel Portraits/female_10_t.png")
},
"jupiter": {
"name": "JUPITER",
"portrait": preload("res://images/Pixel Portraits/female_11_t.png")
}
}
onready var name_node = get_node("DialogueRect/CharacterName")
onready var portrait_node = get_node("PortraitRect/Portrait")
onready var dialogue_node = get_node("DialogueRect/Dialogue")
onready var choice_a_node = get_node("DialogueRect/ChoiceA")
onready var choice_b_node = get_node("DialogueRect/ChoiceB")
onready var select_a_node = get_node("DialogueRect/SelectA")
onready var select_b_node = get_node("DialogueRect/SelectB")
onready var text_timer_node = get_node("TextTimer")
var conversation = [
{
"character": "alex",
"dialogue": "Hey there.\nDo you like apples?",
"choices": [
{
"dialogue": "Sure do!",
"destination": "apples_good"
},
{
"dialogue": "No way, gross!",
"destination": "apples_bad"
}
]
},
{
"character": "alex",
"label": "apples_good",
"dialogue": "You like apples? Me too!",
"destination": "part_2"
},
{
"character": "alex",
"label": "apples_bad",
"dialogue": "You don't?\nThat's a shame."
},
{
"label": "part_2",
"character": "alex",
"dialogue": "I like other fruits too."
},
{
"character": "alex",
"dialogue": "Hey JUPITER, what do you like?"
},
{
"character": "jupiter",
"dialogue": "I prefer oranges..."
},
{
"character": "alex",
"dialogue": "Bananas are my favourite!"
}
]
var current_index = 0
var current_choice = 0
var text_in_progress = false
var skip_text_printing = false
func _ready():
print_dialogue(conversation[current_index]["dialogue"])
func _process(delta):
if text_in_progress:
if Input.is_action_just_pressed("ui_accept"):
skip_text_printing = true
text_timer_node.emit_signal("timeout")
text_timer_node.stop()
return
if current_index < (conversation.size() - 1):
var previous_index = current_index
if Input.is_action_just_pressed("ui_up"):
safe_select_previous_choice()
if Input.is_action_just_pressed("ui_down"):
safe_select_next_choice()
if Input.is_action_just_pressed("ui_accept"):
current_index = get_next_index()
if current_index != previous_index:
print_dialogue(conversation[current_index]["dialogue"])
func get_index_of_label(label):
for i in range(conversation.size()):
if conversation[i].get("label") == label:
return i
assert(false, "Label %s does not exist in this conversation!" % label)
func get_next_index():
var destination = null
if conversation[current_index].has("choices"):
var choice = conversation[current_index]["choices"][current_choice]
destination = choice.get("destination")
else:
destination = conversation[current_index].get("destination")
if destination:
return get_index_of_label(destination)
else:
return current_index + 1
func get_current_choice(choice_index):
var choices = conversation[current_index].get("choices", [])
if choice_index < choices.size():
return choices[choice_index]
else:
return {}
func update_select_indicators():
var select_nodes = [
select_a_node,
select_b_node
]
for node in select_nodes:
node.visible = false
select_nodes[current_choice].visible = true
func get_current_choice_count():
var choices = conversation[current_index].get("choices")
if choices:
return choices.size()
else:
return 1
func safe_select_previous_choice():
current_choice = clamp(current_choice - 1, 0, get_current_choice_count() - 1)
update_select_indicators()
func safe_select_next_choice():
current_choice = clamp(current_choice + 1, 0, get_current_choice_count() - 1)
update_select_indicators()
func reset_selection():
current_choice = 0
update_select_indicators()
func update_character():
var current_character = conversation[current_index].get("character")
portrait_node.texture = characters[current_character]["portrait"]
name_node.text = characters[current_character]["name"]
func show_choices():
set_choices_visible(true)
reset_selection()
choice_a_node.text = get_current_choice(0).get("dialogue", "...")
choice_b_node.text = get_current_choice(1).get("dialogue", "")
func hide_choices():
set_choices_visible(false)
func set_choices_visible(visible):
var nodes = [
select_a_node,
select_b_node,
choice_a_node,
choice_b_node
]
for node in nodes:
node.visible = visible
func print_dialogue( dialogue ):
text_in_progress = true
update_character()
hide_choices()
dialogue_node.text = ""
for letter in dialogue:
text_timer_node.start()
dialogue_node.add_text(letter)
yield(text_timer_node, "timeout")
if skip_text_printing:
skip_text_printing = false
dialogue_node.text = dialogue
break
show_choices()
text_in_progress = false
func skip_text_printing():
skip_text_printing = true
text_timer_node.emit_signal("timeout")
text_timer_node.stop()
What is BBCode? Basically, it allows as to apply formatting to our text by using special BBCode ‘tags’. We can do things like bold or underline text. We can also set the color, and even add other special effects. The Godot docs for RichTextLabel have a lot of good examples for what’s available: https://docs.godotengine.org/en/stable/tutorials/ui/bbcode_in_richtextlabel.html
Let’s get started by enabling BBCode on all our RichTextLabels
. You’ll notice that the Bb Code section has a separate Text
attribute, so when you enable it, you’ll see the original default text disappear. To have it show in the editor, you need to add some text to the Text
section under the Bb Code
heading.
Everywhere in the script we call .text
on one of our RichTextLabel nodes, we need to replace it with .bbcode_text
.
Once you’ve done that, run the scene. Everything should still work as it did before.
Now, let’s try out some BBCode tags. Replace the first part of the conversation with this:
{
"character": "alex",
"dialogue": "Hey there.\nDo you like [wave amp=10 freq=-10][color=green]apples[/color][/wave]?",
"choices": [
{
"dialogue": "[wave amp=10 freq=10]Sure do![/wave]",
"destination": "apples_good"
},
{
"dialogue": "No way, [color=grey][shake rate=10 level=10]gross![/shake][/color]",
"destination": "apples_bad"
}
]
},
Except… oh dear! Our BBCode tags are showing up in our dialogue!
So, what’s going on here? Well, recall that we’re printing out the dialogue by adding the text character by character. Since our dialogue now needs to be interpreted as BBCode text, the RichTextLabel
needs to be aware of the whole dialogue chunk for it to render it.
We’re going to need to change how we print out our dialogue. Luckily, the RichTextLabel
class includes a convenient property called visible_characters
, which we can use to set how much of our dialogue is visible. The best part is, the BBCode tags aren’t included. What we can do is set bbcode_text
to our dialogue upfront, start with visible_characters = 0
, and then increment visible_characters
on each timer tick.
Let’s modify our print_dialogue
function to use visible_characters
instead,
func print_dialogue( dialogue ):
text_in_progress = true
update_character()
hide_choices()
dialogue_node.bbcode_text = dialogue
dialogue_node.visible_characters = 0
for i in dialogue_node.get_total_character_count():
text_timer_node.start()
dialogue_node.visible_characters += 1
yield(text_timer_node, "timeout")
if skip_text_printing:
skip_text_printing = false
dialogue_node.visible_characters = -1
break
show_choices()
text_in_progress = false
You’ll notice also that we’ve changed our loop to iterate over the total character count of the RichTextLabel
rather than the characters in the dialogue string. Again, using get_total_character_count
means that the BBCode tags will be ignored, so our character count will be accurate.
Logically, this should work. However, if you run the scene, you’ll notice that the dialogue doesn’t actually print out - it’s getting stuck. The reason is that get_total_character_count()
is returning 0. This is due to a limitation with the RichTextLabel
class in the engine. Updating visible_characters
(or its counterpart, percent_visible
) won’t update get_total_character_count()
until the next frame.
I’m going to use a simple workaround for this and just wait until the next frame before checking the character count. For this application, a single frame delay isn’t going to be noticable to our user.
Add this line just before our for loop:
yield(get_tree(),"idle_frame")
What we’re doing here is yielding until we receive the "idle_frame"
signal from the SceneTree, which is emitted right before Node._process
. Check out the Godot Docs for more info https://docs.godotengine.org/en/stable/classes/class_scenetree.html#class-scenetree-signal-idle-frame
If you run the scene now, you should see that the BBCode text prints our correctly, character by character.
Let’s say we don’t know ahead of time exactly what our dialogue should be. For example, the player might choose during the game how they’d like to be referred to.
When Alex says “Hey there” at the start of the conversation, perhaps we’d like them to instead use a name or title. Let’s see how we could go about inserting a variable into our dialogue.
For this, I’m going to use formatted strings. You can read about them here https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/gdscript_format_string.html. Basically, the idea is that we can insert named variables between curly brackets {} and then pass in a dictionary of values. Our named variables will then get replaced with the value corresponding with the matching key in the dictionary.
To begin, let’s modify our dialogue to define how we want to express our variables. I’m going to call mine title
, like this:
"dialogue": "Hey {title}.\nDo you like [wave amp=10 freq=-10][color=green]apples[/color][/wave]?"
Now, when we set the bbcode_text
property on the RichTextLabel, we first need to format our string.
Modify the line where we set bbcode_text
in print_dialogue
to this:
dialogue_node.bbcode_text = dialogue.format({ "title": "STRANGER" })
If you run the scene now, you’ll see that Alex now refers to you as STRANGER (which makes sense, they haven’t met us yet).
So, inserting variables is easy, but what about setting them?
Let’s say we want the character to start the conversation by asking the player how they should be referred to.
Add this to the start of our conversation array.
{
"character": "alex",
"dialogue": "Hi, I'm ALEX. How should I refer to you?",
"choices": [
{
"dialogue": "Call me FRIEND"
},
{
"dialogue": "It's CAPTAIN to you"
}
]
},
Alex is now going to start by introducing themselves to you, and ask how they should refer to you.
Obviously, this won’t work yet. What we need is for each choice to modify the title variable that gets passed into our call to format
.
First, we need a title variable, so let’s define one:
var title = "STRANGER"
We also need to use this in our call to format
:
dialogue_node.bbcode_text = dialogue.format({ "title": title })
We now need to figure out how to actually set this variable when each of our options are chosen.
For this, I’m going to introduce another option to our "choices"
dictionary called "call"
.
It’s going to look something like this,
"choices": [
{
"dialogue": "Call me FRIEND",
"call": ["set", "title", "FRIEND"]
},
{
"dialogue": "It's CAPTAIN to you",
"call": ["set", "title", "CAPTAIN"]
}
]
I’ll explain what my idea is here. I’ve added this "call"
key to "choices"
, as in “function to call”. I’m setting the value as an array where the first element is the name of the function to call, and the remaining elements are the arguments for that function. In this case, I’m wanting to call the Object#set function, which takes two arguments: the name of the property to set, and the value to set it to. So, if the player chooses the “Call me FRIEND” option, we’re saying we want to call set("title", "FRIEND")
.
Here’s how we can handle this in the code. Add this function.
func execute_current_choice():
var call_array = get_current_choice(current_choice).get("call")
if call_array:
var call_method = call_array[0]
var call_args = call_array.slice(1, call_array.size())
callv(call_method, call_args)
This function fetches the "call"
value for the currently selected choice and, if it exists, calls the given function with the given arguments. Note, we’re using callv
instead of call
simply because it allows us to provide the arguments as an array.
Finally, we need to call this function when the choice is made. Add this just under where we handle our ‘Enter’ key press for selecting a choice.
if Input.is_action_just_pressed("ui_accept"):
execute_current_choice()
current_index = get_next_index()
One big advantage of doing it this way is it doesn’t just restrict us to setting variables - we can call any function we want with arbitrary arguments, so we can do a lot more than assigning a value! More on that later though…
For now, we’re ready to try this out! Run the scene a couple of times and make a different choice of title. You’ll see that the character refers to you based on the choice you made in the conversation.
In Part 2 we added several features to make our conversations more dynamic and visually interesting.
Continue to Part 3, where we look at integrating the conversation into the context of a larger game, triggering events, and handling larger sections of dialogue.