DEV Community

Alex Pech
Alex Pech

Posted on • Originally published at alexpech.com on

Building a Retro Dialogue plugin for Godot, Part 2

Table of Contents

  1. Part 1 Recap
  2. Introduction
  3. Swapping characters
  4. Animating the text, printing character by character
  5. Adding effects using bbcode
  6. Variables
  7. Conclusion

Part 1 Recap

In Part 1, we looked at:

  • Project setup for a low-res 'SNES-style' game
  • UI layout for our dialogue system
  • Scripting a basic, branching conversation

Introduction

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.

Swapping characters

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
Enter fullscreen mode Exit fullscreen mode

We also need to let our script know about the Portrait node.

onready var portrait_node = get_node("PortraitRect/Portrait")
Enter fullscreen mode Exit fullscreen mode

Now, let's set the name and portrait in our _ready function.

portrait_node.texture = character_portrait
name_node.text = character_name
Enter fullscreen mode Exit fullscreen mode

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.

Screenshot of editor showing custom name and portrait set in inspector

Run the scene. Easy as that, a configurable character name and portrait!

Screenshot of running scene showing configured 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!"
  }
]
Enter fullscreen mode Exit fullscreen mode

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")
  }
}
Enter fullscreen mode Exit fullscreen mode

It should now look like this in the editor:

Screenshot of editor showing new exported character variables

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"]
Enter fullscreen mode Exit fullscreen mode

Finally, let's call this function whenever the conversation progresses.

In _ready,

func _ready():
  update_text_labels()
  update_select_indicators()
  update_character()
Enter fullscreen mode Exit fullscreen mode

and in _process,

if current_index != previous_index:
  update_text_labels()
  update_character()
  reset_selection()
Enter fullscreen mode Exit fullscreen mode

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.

Screenshot of scene running new character codeScreenshot of running scene showing second character in conversation

Animating the text, printing character by character

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.

  1. When text_in_progress is true, we're in the process of printing out text. Our current choices should be hidden.
  2. When 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.

Screenshot of scene hierarchy with TextTimer node added

Text speed can be modified by changing the Wait time of the Timer.

Screenshot of Timer settings with Wait Time set to 0.1 seconds

Make the timer node available in our script.

onready var text_timer_node = get_node("TextTimer")
Enter fullscreen mode Exit fullscreen mode

Add in a variable to represent the new state we need.

var text_in_progress = false
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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"])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

Adding effects using bbcode

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.

Screenshot of RichTextLabel inspector with Bb Code enabled

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"
    }
  ]
},
Enter fullscreen mode Exit fullscreen mode

Screenshot showing BBCode tags within the printed out dialogue

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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.

Variables

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]?"
Enter fullscreen mode Exit fullscreen mode

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" })
Enter fullscreen mode Exit fullscreen mode

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

Screenshot of scene showing character referring to player as STRANGER

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"
    }
  ]
},
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

We also need to use this in our call to format:

dialogue_node.bbcode_text = dialogue.format({ "title": title })
Enter fullscreen mode Exit fullscreen mode

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"]
  }
]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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.

Screenshot of scene with dialogue with ALEX referring to the player as CAPTAIN

Conclusion

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.

Top comments (0)