Breaking Down the Dialogue System of my Gyakuten Saiban Clone in the Godot Engine

In my “PlayScene” Scene, I have a UI Node which instaniates my “DialogueBox” node:

dialogue1 dialogue1

Inside the script for the UI Node:

extends CanvasLayer


signal dialogue_ended(text_id)
onready var dialog = $DialogueBox

func start_dialogue(index, dialog_style = ""):
	dialog.start_dialog(index)

func _on_dialogue_ended(text_id):
	emit_signal("dialogue_ended", text_id)

I have a signal dialogue_ended that calls a text_id. Presumably, it’s for identifying whether or not a specific branch of dialogue has ended, and determines what action the dialogue box is to take next. Under that, there is onready variable, dialogue, which initializes the DialogueBox node the moment the UI Scene is created.

Following, I have a start_dialogue function that takes 2 arguments, an index of some type, and a dialog_style that defaults to null. This function passes the index to the start_dialogue function belonging to the DialogueBox that we already initialized earlier, which we will go into later.

Lastly, I have a _on_dialogue_ended function that’s connected to the signal that we created at the very beginning that takes the text_id from earlier. It then emits the signal that the dialogue has indeed ended, along with the text_id that it ended on for later use.

Going further into the DialogueBox node:

extends Control
class_name Dialogue

signal dialog_ended(text_id)

const PAUSE_TIME_MIN = 0.3
const PAUSE_TIME_MAX = 1
export var pause_time := 0.5 # Pause time in seconds when encountering a PAUSE_CHAR
export var text_speed := 0.04 # Text speed - the seconds to wait until the next character.
# Cannot be < 0.01 due to the limitations of timer timout signal.

var talking = false

onready var dialog_UI := self
onready var text_name := $DialogueBG/NameTag
onready var text_controller := $DialogueBG/TextControl
onready var current_voice := $Sound
onready var timer := $Timer
onready var curr_dialogue_node := $DialogueNode

func _ready():
	_on_set_text_speed(text_speed)
	dialog_UI.hide()
	text_controller.reset_fields()
	

func _input(event):
	if talking and event.is_action_pressed("ui_accept"):
		continue_dialog()

func start_dialog(first_id):
	GameState.talking = true
	talking = true
	set_curr(first_id)
	timer.start()

func end_dialog():
	timer.stop()
	GameState.talking = false
	talking = false
	dialog_UI.hide()

func set_curr(id):
	if id == Globals.END_DIALOG_ID:
		end_dialog()
		emit_signal("dialog_ended", id)
	else:
		curr_dialogue_node.init(id)
		text_name.texture = load(Globals.name_files[0])
		text_controller.complete_sentence = curr_dialogue_node.printable_text
		text_controller.reset_fields()
		current_voice.init(curr_dialogue_node.voice)
		resize_control_nodes()
		for act in curr_dialogue_node.action:
			Globals.execute(act)

func continue_dialog():
	if text_not_all_visible():
		text_controller.print_all_characters(curr_dialogue_node.printable_text)
	else:
		var can_move_to_next_id =! curr_dialogue_node.set_next_text_index()
		if !can_move_to_next_id:
			text_controller.set_dialogue_text(curr_dialogue_node.printable_text)
			text_controller.reset_fields()
		else:
			set_curr(curr_dialogue_node.next_id)
			
		timer.start()

func resize_control_nodes():
	dialog_UI.hide()
	dialog_UI.show()

func text_not_all_visible() -> bool:
	return text_controller.total_visible(curr_dialogue_node.printable_text) < \
		text_controller.total_characters(curr_dialogue_node.printable_text) - 3\
		and text_controller.total_characters(curr_dialogue_node.printable_text) != -1

func _on_timer_timeout():
		var force_voice = false if text_controller.total_visible(curr_dialogue_node.printable_text) == 0 else true
		if text_not_all_visible():
				if curr_dialogue_node.pause_count_at(text_controller.total_visible(curr_dialogue_node.printable_text)) != 0:
						timer.stop()
						yield(get_tree().create_timer(pause_time), "timeout")
						timer.start()
						force_voice = true
					curr_voice.voice(force_voice)
					text_controller.increase_visible_char(curr_dialogue_node.printable_text)
		else:
				timer.stop()

func _on_set_text_speed(speed: float, auto_scale_pause_time = false):
		timer.wait_time = speed

		if auto_scale_pause_time:
				pause_time = clamp(speed * 100, PAUSE_TIME_MIN, PAUSE_TIME_MAX)

func _process(delta):
		print(current_voice_id)

Starting with the few variables I have outlined, I have two constants, PAUSE_TIME_MIN and PAUSE_TIME_MAX, each representing the minimum and maximum time that there can exist a pause between two characters while printing them.

I’ve elso exported a pause_time and text_speed variable to the Godot Engine editor, so that I can easily control their values without having to do so through code. Their functions are pretty self-explanatory.

Continuing, I have some onready variables that call nodes that various nodes that comprise the parts of my DialogueBox. Just to illustrate the nodes being called here:

dialogue2 dialogue2

In my _ready() function, which initializes the child functions upon the start of the application:

func _ready():
	_on_set_text_speed(text_speed)
	dialog_UI.hide()
	text_controller.reset_fields()

The _on_set_text_speed() function takes two arguments, a variable called speed casted to a float, and an auto_scale_pause_time variable, which defaults to false. In the function, we have the following:

func _on_set_text_speed(speed: float, auto_scale_pause_time = false):
	timer.wait_time = speed
	if auto_scale_pause_time:
						pause_time = clamp(speed * 100, PAUSE_TIME_MIN, PAUSE_TIME_MAX)

Wait, what the heck is the wait time? Looking at the description for the wait_time under the Timer class, to which the timer node belongs:

The wait time in seconds.

Note: Timers can only emit once per rendered frame at most (or once per physics frame if process_mode is TIMER_PROCESS_PHYSICS). This means very low wait times (lower than 0.05 seconds) will behave in significantly different ways depending on the rendered framerate. For very low wait times, it is recommended to use a process loop in a script instead of using a Timer node.

So, we’re setting the timer’s wait time to the speed variable(in our case, text_speed). The function then looks to see if the auto_scale_pause_time boolean is set to true; if it is, then we’re setting our pause time to a clamped value consisting of the speed multipled by 100, and limited by the PAUSE_TIME_MIN and PAUSE_TIME_MAX values.

Going back to our _ready() function, we’re hiding our Dialogue Box, in the case that we switch scenes and needs to be “refreshed” upon being called again by the new scene.

Next, we’re going to be diving into our text_controller node’s reset_fields() function.

Before I do, let me describe what the idea was behind this text controller.

Initially, when looking at video footage from playthroughs of Gyakuten Saiban, I noticed that the the text box had very particular spacing when it came to its characters:

dialogue3 dialogue3

In order to recreate this, I had to create three separate text boxes, each with their own separate “lines”. I ended up parenting them to a general control node called TextControl, with the following script:

extends Control

const NEWLINE_CHAR := "^"

onready var text_dialog1 := $DialogBox1
onready var text_dialog2 := $DialogBox2
onready var text_dialog3 := $DialogBox3

var complete_sentence

signal print_all_characters
signal set_dialogue_text
signal reset_fields

func print_all_characters(text):
		complete_sentence = text
		var sent_parts = complete_sentence.split("^")
		var p1_length = sent_parts[0].length()
		var p2_length = sent_parts[1].length()
		var p3_length = sent_parts[2].length()

		text_dialog1.set_visible_characters(p1_length)
		text_dialog2.set_visible_characters(p2_length)
		text_dialog3.set_visible_characters(p3_length)

func set_dialogue_text(text):
		complete_sentence = text
		var sent_parts = complete_sentence.split("^")
		var p1 = sent_parts[0]
		var p2 = sent_parts[1]
		var p3 = sent_parts[2]

		text_dialog1.bbcode_text = p1
		text_dialog2.bbcode_text = p2
		text_dialog3.bbcode_text = p3

func reset_fields():
		text_dialog1.set_visible_characters(0)
		text_dialog2.set_visible_characters(0)
		text_dialog3.set_visible_characters(0)

func increase_visible_char(text)
		complete_sentence = text

		set_dialogue_text(text)
		var total_visible_characters = str(text_dialog1.visible_characters + text_dialog2.visible_characters + text_dialog3.visible_characters)
		var total_char_count = complete_sentence.length() - 3
		var sent_parts = complete_sentence.split("^")
		var p1 = sent_parts[0]
		var p2 = sent_parts[1]
		var p3 = sent_parts[2]

		text_dialog1.visible_characters += 1
		if text_dialog1.visible_characters >= p1.length():
				text_dialog1.visible_characters = p1.length()
				text_dialog2.visible_characters += 1
				if text_dialog2.visible_characters >= p2.length():
						text_dialog2.visible_characters = p2.length()
						text_dialog3.visible_characters += 1
						if text_dialog3.visible characters >= p3.length():
								text_dialog3.visible_characters = p3.length()

func total_visible(text):
		complete_sentence = text
		var total_visible_characters = int(text_dialog1.visible_characters + text_dialog2.visible_characters + text_dialog3.visible_characters)
		return total_visible_characters

func total_characters(text):
		complete_sentence = text
		var total_characters = complete_sentence.length()
		return total_characters

Essentially, the TextControl node splits up the dialogue text fed into it, splits it up into three composite parts (splitting the sentence using the “^” character, which denotes a newline), and can display all text, increase the amount of visible characters one by one, or resetting the fields for new text.

Going back to our DialogueBox node, we’re simply resetting the text fields in the dialog boxes before moving onto our other functions.

The first function that I have after the _ready() function is a simple _input() function which takes an event:

func _input(event):
		if talking and event.is_action_pressed("ui_accept"):
				continue_dialog()

This continue_dialog() function is something I wrote further down in the script:

func continue_dialog():
		if text_not_all_visible():
				text_controller.print_all_characters(curr_dialogue_node.printable_text)
		else:
				var can_move_to_next_id =! curr_dialogue_node.set_next_text_index()
				if !can_move_to_next_id:
						text_controller.set_dialogue_text(curr_dialogue_node.printable_text)
						text_controller.reset_fields()
				else:
						set_curr(curr_dialogue_node.next_id)
																																
				timer.start()

The first thing this function checks is boolean, text_not_all_visible(), which simply returns true if the number of visible characters in dialogue is less than the total number of characters - 3 (for the 3 “^” which I’m using to split the sentence into parts):

func text_not_all_visible() -> bool:
		return text_controller.total_visible(curr_dialogue_node.printable_text) < \
				text_controller.total_characters(curr_dialogue_node.printable_text) - 3