How do you allow the player to make choices in a conversation? This is what we will see in this lesson. The hardest part with choices is really writing branching conversations.
In visual novels, like most dialogue-heavy games, choices can open branches that soon close back down. Even in a game like Mass Effect, that feels like you have so much control over the story, most choices affect only one or several lines of dialogue. The challenge is figuring out how to close these little arcs down while giving the illusion of control to the player.
The reason is just the sheer amount of work involved in writing different dialogues and, for big games, related animations, camera placement, and voice-over to record.
Anyway, let’s get to the choice system’s design. I went for a plain vertical list: it takes advantage of the text box’s panel and displays inside of it.
It’s a little reminiscent of my RPG Maker days.
When the ScenePlayer
reaches a player choice, we hide the name label and the text, and display a list of options in the middle.
Create a new scene with a VBoxContainer
node as the root and name it ChoiceSelector. This will be the only node in this scene as we’ll create buttons programmatically.
Apply Layout -> Full Rect to make the node span the entire viewport. It will make the node expand to fit its parent’s bounding-box by default.
You can save the scene in the TextBox/
folder and attach a script to the ChoiceSelector.
The default buttons don’t work well at all with our text box’s look.
We need to update our theme resource to fix that.
Open theme.tres
and in the Theme bottom panel, click Edit Theme -> Add Class Items, set the Type to “Button” and click Add All.
You should see a new button category appear in the Inspector, below the Default Font property. Expand it and expand the styles subcategory.
There, you can add resources that change the look of the buttons’ background. We will make the button transparent in most of its states, so only its text stays visible. The only exception is the focus state, which will highlight the currently selected button.
Right-click on Styles -> Disabled and create a new StyleBoxEmpty. This resource type doesn’t draw anything, making the button’s background transparent in its disabled state.
Do the same for the Hover, Normal, and Pressed styles. In the theme preview, you should now only see the text “Button” without a background or border, and that even if you hover it with the mouse or press it.
We want to add a different style to the Focus state to outline the player’s selected choice.
Right-click the field next to Focus and create a new StyleBoxFlat. Click on the resource to edit it.
This resource defines a simple geometric shape with a background color, a border, round corners, and optional shadow and content margins.
It results in a mostly flat panel and works well when you have simple needs.
I set the Bg Color to a barely visible light blue, with an alpha value of 11
.
Then, to give focused buttons an outline, set the Border Width Left, Top, Right, and Bottom to 2
pixels. For the Border Color, I used a transparent light and saturated blue.
To round the corners, set the four Corner Radius properties value of 8
pixels. These values control the radius of each corner.
With that, you can click on the button in the Preview to see an outline appear around it.
With that, we can start coding.
Open the ChoiceSelector.gd
script. In it, we’ll define a display()
function.
The display()
function will directly use data from the ScenePlayer
’s _scene_data
. Each choice needs a label that will display as text and a number representing the next key to target in the scene player.
Every time we display choices, we create buttons and connect to that pressed signal. We bind the target
value to the _on_Button_pressed()
callback.
# Displays a list of choices the player can navigate and select from. class_name ChoiceSelector extends VBoxContainer ## Emitted when the player presses one of the choice buttons. signal choice_made(target_id) # Individual choices are dictionaries with a `label` and a `target` key. # We get choices as an array of dictionaries and create a button for each of # them. func display(choices: Array) -> void: for choice in choices: var button := Button.new() button.text = choice.label # We bind the target node key to the `_on_Button_pressed()` callback # function as we need to emit it along with the `choice_made` signal. button.connect("pressed", self, "_on_Button_pressed", [choice.target]) add_child(button) # We give the first button focus, allowing the player to navigate them with # the keyboard. (get_child(0) as Button).grab_focus()
Whenever the player makes a choice, we delete all the buttons and emit the target ID.
func _on_Button_pressed(target_id: int) -> void: emit_signal("choice_made", target_id) _clear() func _clear() -> void: for child in get_children(): child.queue_free()
The data I chose for choices looks like the following. It’s an array of dictionaries, each containing a label
and target
key.
[ {label = "Hi Sophia!", target = 5}, {label = "Do I know you?", target = 6}, {label = "Get out of my way!", target = 7} ]
Next, we need to wire the ChoiceSelector
to the TextBox
and the ScenePlayer
.
Open the TextBox
scene and create an instance of the ChoiceSelector
as a child of the TextBox node.
To preview its look, you can add buttons as children of it with some placeholder text.
By default, they expand to fill the VBoxContainer
horizontally, making them too large.
We can address that by resizing the ChoiceSelector. It should take between 20 and 30% of the panel’s width.
You also want to adjust the vertical margins so the buttons don’t touch the text box’s top edge.
Once you are satisfied with the size and margins, delete the buttons as we’ll generate them as needed using code.
To display choices, we define a new display_choices()
function on the TextBox
script. It should hide the RichTextLabel and any element we don’t want to have visible, then delegate choices to the selector.
When the player decided, we bubble up the ChoiceSelector.choice_made
signal and reset the scene’s visibility.
Add the following code to TextBox.gd
.
## Emitted when the player made a choice. The argument is the ID of the target ## scene node to jump to. signal choice_made(target_id) onready var _choice_selector: ChoiceSelector = $ChoiceSelector onready var _name_background: TextureRect = $NameBackground func _ready() -> void: #... ## The text box forwards the `choice_made` signal to the scene player via a ## signal. _choice_selector.connect("choice_made", self, "_on_ChoiceSelector_choice_made") # As the choices live as a child of the text box, we have to hide nodes manually, # which we do here. # The scene player can call this method to display choices. func display_choice(choices: Array) -> void: _name_background.hide() _rich_text_label.hide() _blinking_arrow.hide() _choice_selector.display(choices) # When the player made a choice, we forward the signal to the scene player so it # can jump to the node corresponding to `target_id`. And we reset # visibility. func _on_ChoiceSelector_choice_made(target_id: int) -> void: emit_signal("choice_made", target_id) _name_background.show() _rich_text_label.show()
In the scene player’s run_scene()
function, we add a new block to manage choices. If the current node has a key named “choices”, we call TextBox.display_choice()
and wait for it to emit the choice_made
signal. It gives us the index of our next key.
At this point, we can also add support for restarting the scene, for lack of a better place to cover this bite-sized feature.
Open ScenePlayer.gd
and add the following code.
## Emitted when a scene node contains an instruction to restart the scene. You ## could do that when you make a choice that leads you to a game over, for ## instance. signal restart_requested const KEY_RESTART_SCENE := -2 func run_scene() -> void: #... #while key != KEY_END_OF_SCENE: #... #if "line" in node: #... #elif "transition" in node: #... elif "choices" in node: _text_box.display_choice(node.choices) # The text box's `choice_made` signal returns the key of the node # corresponding to the choice the player made. key = yield(_text_box, "choice_made") # If the choice causes a scene restart, we emit a signal and end the # function. # A higher-level system will take care of reloading the scene. if key == KEY_RESTART_SCENE: emit_signal("restart_requested") return #else: #...
Our restart mechanism is basic here, but note you could use similar logic to have nodes that request jumping to a specific scene depending on the player’s choices.
To test the choices, you can set ScenePlayer._scene_data
to the following. It’ll fade the scene in and display three choices. All options end the scene as we lack more steps to play with.
Also, be sure to call run_scene()
from within _ready()
.
var _scene_data := { 0: { transition = "fade_in", next = 1 }, 1: { choices = [ {label = "Hi Sophia!", target = -1}, {label = "Do I know you?", target = -1}, {label = "Get out of my way!", target = -1}, ], } }
With that, we’re getting close to having every feature in place.
In the next lesson, we’ll add a “main” node to our game, which will take care of sequencing scenes.
Here are the complete scripts for the files we modified in this lesson.
ChoiceSelector.gd
class_name ChoiceSelector extends VBoxContainer signal choice_made(target_id) func display(choices: Array) -> void: for choice in choices: var button := Button.new() button.text = choice.label button.connect("pressed", self, "_on_Button_pressed", [choice.target]) add_child(button) (get_child(0) as Button).grab_focus() func _on_Button_pressed(target_id: int) -> void: emit_signal("choice_made", target_id) _clear() func _clear() -> void: for child in get_children(): child.queue_free()
TextBox.gd
extends Control signal display_finished signal next_requested signal choice_made(target_id) 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 onready var _choice_selector: ChoiceSelector = $ChoiceSelector onready var _name_background: TextureRect = $NameBackground 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") _choice_selector.connect("choice_made", self, "_on_ChoiceSelector_choice_made") 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 display_choice(choices: Array) -> void: _name_background.hide() _rich_text_label.hide() _blinking_arrow.hide() _choice_selector.display(choices) 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() func _on_ChoiceSelector_choice_made(target_id: int) -> void: emit_signal("choice_made", target_id) _name_background.show() _rich_text_label.show()
ScenePlayer.gd
class_name ScenePlayer extends Node signal scene_finished signal transition_finished signal restart_requested const KEY_END_OF_SCENE := -1 const KEY_RESTART_SCENE := -2 const TRANSITIONS := { fade_in = "_appear_async", fade_out = "_disappear_async", } var _scene_data := {} onready var _text_box := $TextBox onready var _character_displayer := $CharacterDisplayer onready var _background := $Background onready var _anim_player: AnimationPlayer = $FadeAnimationPlayer func run_scene() -> void: var key = _scene_data.keys()[0] while key != KEY_END_OF_SCENE: var node: Dictionary = _scene_data[key] var character: Character = ( ResourceDB.get_character(node.character) if "character" in node else ResourceDB.get_narrator() ) if "background" in node: var bg: Background = ResourceDB.get_background(node.background) _background.texture = bg.texture if "character" in node: var side: String = node.side if "side" in node else CharacterDisplayer.SIDE.LEFT var animation: String = node.get("animation", "") var expression: String = node.get("expression", "") _character_displayer.display(character, side, expression, animation) if not "line" in node: yield(_character_displayer, "display_finished") if "line" in node: _text_box.display(node.line, character.display_name) yield(_text_box, "next_requested") key = node.next elif "transition" in node: call(TRANSITIONS[node.transition]) yield(self, "transition_finished") key = node.next elif "choices" in node: _text_box.display_choice(node.choices) key = yield(_text_box, "choice_made") if key == KEY_RESTART_SCENE: emit_signal("restart_requested") return else: key = node.next _character_displayer.hide() emit_signal("scene_finished") func _appear_async() -> void: _anim_player.play("fade_in") yield(_anim_player, "animation_finished") yield(_text_box.fade_in_async(), "completed") emit_signal("transition_finished") func _disappear_async() -> void: yield(_text_box.fade_out_async(), "completed") _anim_player.play("fade_out") yield(_anim_player, "animation_finished") emit_signal("transition_finished")