Now that we have most of our game set up, we can go ahead and create an NPC with a Quest. Please note that this quest system will not be a dynamic system with quest chains. No, unfortunately, this quest system only contains a simple collection quest with a dialog and state tree. I left it to be this simple system because a dialog, quest, and even inventory system is quite complex, and therefore I will make a separate tutorial series that will focus on those advanced concepts in isolation!
WHAT YOU WILL LEARN IN THIS PART:
· How to work with game states.
· How to add dialog trees.
· Further practice with popups and animations.
· How to work with the InputEventKey object.
· How to add Groups to nodes.
· How to work with the RayCast2D node.
If you are curious about how these systems might work, here are some written resources that could give you an idea of how to implement this:
https://github.com/christinec-dev/DustyTrailsParts/tree/main/Notes
https://www.reddit.com/r/godot/comments/m3ghec/how_to_build_a_dialog_system_without_selling_your/
https://www.linkedin.com/advice/1/what-best-ways-design-dynamic-immersive-quests-open-world
https://medium.com/@thrivevolt/making-a-grid-inventory-system-with-godot-727efedb71f7
NPC SETUP
Let’s create our NPC scene. In your project, create a new scene with a CharacterBody2D node as the scene root, and an AnimatedSprite2D and CollisionBody2D node as its children. Make the collision shape for your collision node to be a RectangleShape. Save this scene underneath your Scenes folder.
Let’s add two new animations to the AnimatedSprite2D node: idle_down and talk_down. You can find the animations spritesheet that I used for this node underneath Assets > Mobs > Coyote.
idle_down:
talk_down:
For now, our NPC will be anchored in one place, and they won’t attack our enemies. Let me know if you want me to show you how to make them roam around the map and attack enemies and vice versa. Leave the FPS and Looping as their default values.
Please make sure that your sprite is in the middle of your CollisionShape.
Attach a script to your NPC scene and save it underneath your Scripts folder.
We also want to add a Group to this node so that our players can check for the special “NPC” group when they interact with it. We will have to also add a RayCast2D node to our player so that we can check its “target”, and if that target is part of the NPC group, we’ll launch the function to talk to the NPC.
DIALOG POPUP & PLAYER SETUP
Now, in your Player scene, we need to create another popup node that will be made visible when the player interacts with the NPC. Add a new CanvasLayer node and rename it to “DialogPopup”.
In this DialogPopup node, let’s add a ColorRect with three Label nodes as its children. Rename it as follows:
Select your Dialog node and change its Color property to #581929. Change its size (x: 310, y: 70), and its anchor_preset (center_bottom).
Select your NPC Label node and change its text to “Name”. Change its size (x: 290, y: 26); position (x: 5, y: 2).
Now change its font to “Scrondinger”, and its font size to 10. We also want to change its font color, so underneath Color > Font Color, change the color to #B26245.
Select your Message Label node and change its text to “Text here…”. Change its size (x: 290, y: 30); position (x: 5, y: 15). Also change its font to “Scrondinger”, and its font size to 10. The AutoWrap property should also be set to “Word”.
Select your Response Label node and change its text to “Answer”. Change its size (x: 290, y: 16); position (x: 5, y: 60); horizontal and vertical alignment (center). Also, change its font to “Scrondinger”, and its font size to 10. Underneath Color > Font Color, change the color to #D6c376.
Change the DialogPopup’s visibility to be hidden and add a RayCast2D node before your UI layer.
In your Player script, we have to do the same as we did in our Enemy script when we set the direction of our raycast node to be the same as the direction of our character. In your _physics_process() function, let’s turn the RayCast2D node to follow our player’s movement direction.
### Player.gd
extends CharacterBody2D
# Node references
@onready var animation_sprite = $AnimatedSprite2D
@onready var health_bar = $UI/HealthBar
@onready var stamina_bar = $UI/StaminaBar
@onready var ammo_amount = $UI/AmmoAmount
@onready var stamina_amount = $UI/StaminaAmount
@onready var health_amount = $UI/HealthAmount
@onready var xp_amount = $UI/XP
@onready var level_amount = $UI/Level
@onready var animation_player = $AnimationPlayer
@onready var level_popup = $UI/LevelPopup
@onready var ray_cast = $RayCast2D
# --------------------------------- Movement & Animations -----------------------------------
func _physics_process(delta):
# Get player input (left, right, up/down)
var direction: Vector2
direction.x = Input.get_action_strength("ui_right") - Input.get_action_strength("ui_left")
direction.y = Input.get_action_strength("ui_down") - Input.get_action_strength("ui_up")
# Normalize movement
if abs(direction.x) == 1 and abs(direction.y) == 1:
direction = direction.normalized()
# Sprinting
if Input.is_action_pressed("ui_sprint"):
if stamina >= 25:
speed = 100
stamina = stamina - 5
stamina_updated.emit(stamina, max_stamina)
elif Input.is_action_just_released("ui_sprint"):
speed = 50
# Apply movement if the player is not attacking
var movement = speed * direction * delta
if is_attacking == false:
move_and_collide(movement)
player_animations(direction)
# If no input is pressed, idle
if !Input.is_anything_pressed():
if is_attacking == false:
animation = "idle_" + returned_direction(new_direction)
# Turn RayCast2D toward movement direction
if direction != Vector2.ZERO:
ray_cast.target_position = direction.normalized() * 50
This raycast will hit any node that has a collision body assigned to it. We want it to hit our NPC, and if it does and we press our interaction button, it will launch the dialog popup and run the dialog tree. For this, we need to first add a new input action that we can press to interact with NPCs.
In your Input Actions menu in your Project Settings, add a new input called “ui_interact” and assign a key to it. I assigned the TAB key on my keyboard to this action.
Now, in our input() function, we will expand on it to add an input event for our ui_interact action. If our player presses this button, our raycast will capture the colliders it’s hitting, and if one of those colliders is part of the “NPC” group, it will launch the NPC dialog. We’ve done something similar to this in our Enemy scene.
### Player.gd
func _input(event):
#input event for our attacking, i.e. our shooting
if event.is_action_pressed("ui_attack"):
#checks the current time as the amount of time passed in milliseconds since the engine started
var now = Time.get_ticks_msec()
#check if player can shoot if the reload time has passed and we have ammo
if now >= bullet_fired_time and ammo_pickup > 0:
#shooting anim
is_attacking = true
var animation = "attack_" + returned_direction(new_direction)
animation_sprite.play(animation)
#bullet fired time to current time
bullet_fired_time = now + bullet_reload_time
#reduce and signal ammo change
ammo_pickup = ammo_pickup - 1
ammo_pickups_updated.emit(ammo_pickup)
#using health consumables
elif event.is_action_pressed("ui_consume_health"):
if health > 0 && health_pickup > 0:
health_pickup = health_pickup - 1
health = min(health + 50, max_health)
health_updated.emit(health, max_health)
health_pickups_updated.emit(health_pickup)
#using stamina consumables
elif event.is_action_pressed("ui_consume_stamina"):
if stamina > 0 && stamina_pickup > 0:
stamina_pickup = stamina_pickup - 1
stamina = min(stamina + 50, max_stamina)
stamina_updated.emit(stamina, max_stamina)
stamina_pickups_updated.emit(stamina_pickup)
#interact with world
elif event.is_action_pressed("ui_interact"):
var target = ray_cast.get_collider()
if target != null:
if target.is_in_group("NPC"):
# Talk to NPC
#todo: add dialog function to npc
return
Next up we want our NPC script to update the DialogPopup node in our Player scene’s labels (npc, message, and response). We also want it to handle the player’s input for the dialog’s response, so if the player says “A” or “B” for yes/no the dialog popup will update the message values according to the dialog tree. We’ll get more into this when we set up our dialog tree later on.
Attach a new script to your DialogPopup node and save it underneath your GUI folder.
Sometimes, you want a class’s member variable to do more than just hold data and perform some validation or computation whenever its value changes. It may also be desired to encapsulate its access in some way. For this, GDScript provides a special syntax to define properties using the set and get keywords after a variable declaration. Then you can define a code block that will be executed when the variable is accessed or assigned.
At the top of this script, we need to declare a few variables. We will define these variables using our set/get properties, which will allow us to set our Label values from the data that we get from our NPC script. We also need to define a variable for our NPC reference.
### DialogPopup.gd
extends CanvasLayer
#gets the values of our npc from our NPC scene and sets it in the label values
var npc_name : set = npc_name_set
var message: set = message_set
var response: set = response_set
#reference to NPC
var npc
Next, we need to create three functions to set the values of our set/get variables. This will capture the value passed from our NPC and assign the value to our Labels.
### DialogPopup.gd
extends CanvasLayer
#gets the values of our npc from our NPC scene and sets it in the label values
var npc_name : set = npc_name_set
var message: set = message_set
var response: set = response_set
#reference to NPC
var npc
#sets the npc name with the value received from NPC
func npc_name_set(new_value):
npc_name = new_value
$Dialog/NPC.text = new_value
#sets the message with the value received from NPC
func message_set(new_value):
message = new_value
$Dialog/Message.text = new_value
#sets the response with the value received from NPC
func response_set(new_value):
response = new_value
$Dialog/Response.text = new_value
Before we continue with this script, let’s add an animation to our Message node so that it has a typewriter effect. This is a classic feature in RPG-type games, so we will also implement this. In Godot 4, we can do this by changing our text’s visible ratio in the AnimationPlayer. This will transition our text visibility slowly from invisible to visible.
In your AnimationPlayer node in your Player scene, add a new animation called “typewriter”.
Add a new Property Track to your animation and assign it to your Message node.
The property we want to change is the visible ratio of our dialog text.
This visible ratio will make the dialog ratio visible from 0 to 1. In your visible_ratio track, add two new keys, one at keyframe 0 and the other one at keyframe 1.
Change the Value of your keyframe 0 to 0, and the Value of your keyframe 1 to 1.
Now if you play your animation, your typewriter effect should work!
Back in your DialogPopup code, let’s create two functions that will be called by our NPC script. The first function should pause the game and show the dialog popup, and play the typewriter animation. The other function should hide the dialog and unpause the game.
### DialogPopup.gd
extends CanvasLayer
# Node refs
@onready var animation_player = $"../../AnimationPlayer"
#gets the values of our npc from our NPC scene and sets it in the label values
var npc_name : set = npc_name_set
var message: set = message_set
var response: set = response_set
#reference to NPC
var npc
#sets the npc name with the value received from NPC
func npc_name_set(new_value):
npc_name = new_value
$Dialog/NPC.text = new_value
#sets the message with the value received from NPC
func message_set(new_value):
message = new_value
$Dialog/Message.text = new_value
#sets the response with the value received from NPC
func response_set(new_value):
response = new_value
$Dialog/Response.text = new_value
#opens the dialog
func open():
get_tree().paused = true
self.visible = true
animation_player.play("typewriter")
#closes the dialog
func close():
get_tree().paused = false
self.visible = false
If this node is hidden and if the message text has not been completed yet, this node should not receive input. So, in the _ready() function, we must call the set_process_input() function to disable input handling. This will disable the input function and Input singleton in our Player script from processing any input.
### DialogPopup.gd
# older code
# ------------------- Processing ---------------------------------
#no input on hidden
func _ready():
set_process_input(false)
#opens the dialog
func open():
get_tree().paused = true
self.visible = true
animation_player.play("typewriter")
#closes the dialog
func close():
get_tree().paused = false
self.visible = false
We only want the player to be able to insert inputs if our “typewriter” animation has finished animating our message text. Therefore, we can connect the AnimationPlayer node’s animation_finished() signal to our DialogPopup script.
### DialogPopup.gd
# older code
# ------------------- Processing ---------------------------------
#no input on hidden
func _ready():
set_process_input(false)
#opens the dialog
func open():
get_tree().paused = true
self.visible = true
animation_player.play("typewriter")
#closes the dialog
func close():
get_tree().paused = false
self.visible = false
#input after animation plays
func _on_animation_player_animation_finished(anim_name):
set_process_input(true)
Finally, we can write our code that will accept the player’s input in response to our dialog options. We don’t have to create unique input actions for this, as we can just use the InputEventKey object. This object stores key presses on the keyboard. So if we press “A” or “B”, the object will capture those keys as input and trigger the dialog tree to respond to these inputs.
Finally, we can write our code that will accept the player’s input in response to our dialog options. We don’t have to create unique input actions for this, as we can just use the InputEventKey object. This object stores key presses on the keyboard. So if we press “A” or “B”, the object will capture those keys as input and trigger the dialog tree to respond to these inputs.
Here’s a visual representation to help you understand what we want to achieve:
### DialogPopup.gd
#older code
# ------------------- Dialog -------------------------------------
func _input(event):
if event is InputEventKey:
if event.is_pressed():
if event.keycode == KEY_A:
#todo: add dialog function to npc
return
elif event.keycode == KEY_B:
#todo: add dialog function to npc
return
NPC Dialog Tree
What is a Dialog Tree?
A dialog tree, often referred to as a conversation tree or branching dialog, is a form of interactive narrative. It represents a series of branching choices in character dialogues or interactions, allowing players or users to navigate through various conversation paths based on their choices.
We’ll come back to this _input function once we have the dialog function in our NPC script. We’re going to start working on that now, so let’s open up our NPC script and define some variables that will store our quest and dialog states. We’ll create an enum that will hold the states of our Quest — which is when it’s not started, started, and completed. then we will instance that enum to set its initial state to be NOT_STARTED. We then need to store the state of our dialog as an integer.
We’ll define our dialog state as an integer so that we can increment it throughout our dialog tree, and then we can select our dialog based on our number value in a match statement. A match statement is used to branch the execution of a program. It’s the equivalent of the switch statement found in many other languages, and it works in the way that an expression is compared to a pattern, and if that pattern matches, the corresponding block will be executed. After that, the execution continues below the match statement.
Our dialog and quest states will be executed in this match-case pattern:
match (quest_status):
NOT_STARTED:
match (dialog_state):
0:
//dialog message
//increase dialog state
match (answer):
//dialog message
//increase dialog state
1:
//dialog message
//increase dialog state
match (answer):
//dialog message
//increase dialog state
STARTED:
match (dialog_state):
0:
//dialog message
//increase dialog state
match (answer):
//dialog message
//increase dialog state
1:
//dialog message
//increase dialog state
match (answer):
//dialog message
//increase dialog state
COMPLETED:
match (dialog_state):
0:
//dialog message
//increase dialog state
match (answer):
//dialog message
Let’s define our variables.
### NPC
extends CharacterBody2D
#quest and dialog states
enum QuestStatus { NOT_STARTED, STARTED, COMPLETED }
var quest_status = QuestStatus.NOT_STARTED
var dialog_state = 0
var quest_complete = false
We also need to define some variables that will reference our DialogPopup node in our Player scene, as well as our Player itself — because our player is the one that will initiate the interaction with our NPC.
### NPC
extends CharacterBody2D
# Node refs
@onready var dialog_popup = get_tree().root.get_node("Main/Player/UI/DialogPopup")
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
#quest and dialog states
enum QuestStatus { NOT_STARTED, STARTED, COMPLETED }
var quest_status = QuestStatus.NOT_STARTED
var dialog_state = 0
var quest_complete = false
I also want us to type in the NPC’s name in the Inspector panel, instead of giving them a constant value like “Joe”. To do this, we can export our variable.
### NPC
extends CharacterBody2D
# Node refs
@onready var dialog_popup = get_tree().root.get_node("Main/Player/UI/DialogPopup")
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
#quest and dialog states
enum QuestStatus { NOT_STARTED, STARTED, COMPLETED }
var quest_status = QuestStatus.NOT_STARTED
var dialog_state = 0
var quest_complete = false
#npc name
@export var npc_name = ""
In our ready() function we need to set the default animation of our NPC to be “idle_down”.
### NPC
extends CharacterBody2D
# Node refs
@onready var dialog_popup = get_tree().root.get_node("Main/Player/UI/DialogPopup")
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
#quest and dialog states
enum QuestStatus { NOT_STARTED, STARTED, COMPLETED }
var quest_status = QuestStatus.NOT_STARTED
var dialog_state = 0
var quest_complete = false
#npc name
@export var npc_name = ""
#initialize variables
func _ready():
animation_sprite.play("idle_down")
Now we can create our dialog() function. This dialog tree is quite long, so I recommend you go ahead and just copy and paste it from below. A dialog tree, or conversation tree, is a gameplay mechanic that runs when a player character interacts with an NPC. In this tree, the player is given a choice of what to say and makes subsequent choices until the conversation ends.
In our dialog tree, our NPC runs our Player through a quest to go and find a recipe book. We have not yet created this recipe book, or quest item, which will call our NPC to notify them that the quest has been completed. If we haven’t gotten this quest item yet, the NPC will remind us to get it. If we’ve gotten this quest item, the NPC will thank us and reward us, and complete the quest.
### NPC
extends CharacterBody2D
# Node refs
@onready var dialog_popup = get_tree().root.get_node("Main/Player/UI/DialogPopup")
@onready var player = get_tree().root.get_node("Main/Player")
@onready var animation_sprite = $AnimatedSprite2D
#quest and dialog states
enum QuestStatus { NOT_STARTED, STARTED, COMPLETED }
var quest_status = QuestStatus.NOT_STARTED
var dialog_state = 0
var quest_complete = false
#npc name
@export var npc_name = ""
#initialize variables
func _ready():
animation_sprite.play("idle_down")
#dialog tree
func dialog(response = ""):
# Set our NPC's animation to "talk"
animation_sprite.play("talk_down")
# Set dialog_popup npc to be referencing this npc
dialog_popup.npc = self
dialog_popup.npc_name = str(npc_name)
# dialog tree
match quest_status:
QuestStatus.NOT_STARTED:
match dialog_state:
0:
# Update dialog tree state
dialog_state = 1
# Show dialog popup
dialog_popup.message = "Howdy Partner. I haven't seen anybody round these parts in quite a while. That reminds me, I recently lost my momma's secret recipe book, can you help me find it?"
dialog_popup.response = "[A] Yes [B] No"
dialog_popup.open() #re-open to show next dialog
1:
match response:
"A":
# Update dialog tree state
dialog_state = 2
# Show dialog popup
dialog_popup.message = "That's mighty kind of you, thanks."
dialog_popup.response = "[A] Bye"
dialog_popup.open() #re-open to show next dialog
"B":
# Update dialog tree state
dialog_state = 3
# Show dialog popup
dialog_popup.message = "Well, I'll be waiting like a tumbleweed 'till you come back."
dialog_popup.response = "[A] Bye"
dialog_popup.open() #re-open to show next dialog
2:
# Update dialog tree state
dialog_state = 0
quest_status = QuestStatus.STARTED
# Close dialog popup
dialog_popup.close()
# Set NPC's animation back to "idle"
animation_sprite.play("idle_down")
3:
# Update dialog tree state
dialog_state = 0
# Close dialog popup
dialog_popup.close()
# Set NPC's animation back to "idle"
animation_sprite.play("idle_down")
QuestStatus.STARTED:
match dialog_state:
0:
# Update dialog tree state
dialog_state = 1
# Show dialog popup
dialog_popup.message = "Found that book yet?"
if quest_complete:
dialog_popup.response = "[A] Yes [B] No"
else:
dialog_popup.response = "[A] No"
dialog_popup.open()
1:
if quest_complete and response == "A":
# Update dialog tree state
dialog_state = 2
# Show dialog popup
dialog_popup.message = "Yeehaw! Now I can make cat-eye soup. Here, take this."
dialog_popup.response = "[A] Bye"
dialog_popup.open()
else:
# Update dialog tree state
dialog_state = 3
# Show dialog popup
dialog_popup.message = "I'm so hungry, please hurry..."
dialog_popup.response = "[A] Bye"
dialog_popup.open()
2:
# Update dialog tree state
dialog_state = 0
quest_status = QuestStatus.COMPLETED
# Close dialog popup
dialog_popup.close()
# Set NPC's animation back to "idle"
animation_sprite.play("idle_down")
# Add pickups and XP to the player.
player.add_pickup(Global.Pickups.AMMO)
player.update_xp(50)
3:
# Update dialog tree state
dialog_state = 0
# Close dialog popup
dialog_popup.close()
# Set NPC's animation back to "idle"
animation_sprite.play("idle_down")
QuestStatus.COMPLETED:
match dialog_state:
0:
# Update dialog tree state
dialog_state = 1
# Show dialog popup
dialog_popup.message = "Nice seeing you again partner!"
dialog_popup.response = "[A] Bye"
dialog_popup.open()
1:
# Update dialog tree state
dialog_state = 0
# Close dialog popup
dialog_popup.close()
# Set NPC's animation back to "idle"
animation_sprite.play("idle_down")
Figure 1: Visual Representation of our Dialog Tree.
QUEST ITEM SETUP
For this quest item, we can duplicate our Pickups scene and rename it to “QuestItem”. Detach the Pickups script and signal from the newly created scene and rename its root to “QuestItem”. Delete the script and signal from the root node.
Change its sprite to be anything you want. Since our NPC is looking for a recipe book, I’m going to change my sprite to “book_02d.png”, which can be found under the Assets > Icons directory.
Attach a new script to the root of this scene and save it underneath your Scripts folder. Also, connect its body_entered() signal to this new script.
In this script, we need to get a reference to our NPC scene which we will instance in our Main scene. From this, we’ll need to see if the Player has entered the body of this scene, and if true, we can call our to our NPC to change its quest status to complete — since we’ve found the requirements to complete the quest.
### QuestItem.gd
extends Area2D
#npc node reference
@onready var npc = get_tree().root.get_node("Main/SpawnedNPC/NPC")
#if the player enters the collision body, destroy item and update quest
func _on_body_entered(body):
if body.name == "Player":
print("Quest item obtained!")
get_tree().queue_delete(self)
npc.quest_complete = true
Let’s instance our NPC and our Quest Item in our Main scene underneath two new nodes called SpawnedQuestItems and SpawnedNPC (both should be Node2D nodes).
Here is an example of my map’s layout for my quest:
Now we need to go back to our Player scene so that we can update our interact input to call the dialog function in our NPC script.
### Player.gd
# older code
func _input(event):
#input event for our attacking, i.e. our shooting
if event.is_action_pressed("ui_attack"):
#checks the current time as the amount of time passed
var now = Time.get_ticks_msec()
#check if player can shoot if the reload time has passed and we have ammo
if now >= bullet_fired_time and ammo_pickup > 0:
#shooting anim
is_attacking = true
var animation = "attack_" + returned_direction(new_direction)
animation_sprite.play(animation)
#bullet fired time to current time
bullet_fired_time = now + bullet_reload_time
#reduce and signal ammo change
ammo_pickup = ammo_pickup - 1
ammo_pickups_updated.emit(ammo_pickup)
#using health consumables
elif event.is_action_pressed("ui_consume_health"):
if health > 0 && health_pickup > 0:
health_pickup = health_pickup - 1
health = min(health + 50, max_health)
health_updated.emit(health, max_health)
health_pickups_updated.emit(health_pickup)
#using stamina consumables
elif event.is_action_pressed("ui_consume_stamina"):
if stamina > 0 && stamina_pickup > 0:
stamina_pickup = stamina_pickup - 1
stamina = min(stamina + 50, max_stamina)
stamina_updated.emit(stamina, max_stamina)
stamina_pickups_updated.emit(stamina_pickup)
#interact with world
#interact with world
elif event.is_action_pressed("ui_interact"):
var target = ray_cast.get_collider()
if target != null:
if target.is_in_group("NPC"):
# Talk to NPC
target.dialog()
return
We also need to add our dialog functions to our DialogPopup’s input code to capture our A and B responses.
### DialogPopup.gd
#older code
# ------------------- Dialog -------------------------------------
func _input(event):
if event is InputEventKey:
if event.is_pressed():
if event.keycode == KEY_A:
npc.dialog("A")
elif event.keycode == KEY_B:
npc.dialog("B")
So now if our player were to run into our NPC and press TAB, the dialog popup should be made visible. We can then navigate through the conversation with our NPC by responding with our “A” for yes and “B” for no keys. If we get the quest item and we go back to the NPC, the dialog options should update, and we should receive our Pickups as a reward. We’ve then reached the end of our dialog tree, so if we return to our NPC, the last line will play.
Before we can test this out, we need to change our node’s process mode, because the DialogPopup will pause the game. Change the NPC’s process mode to “Always” because we need their animations to play even if the game is paused — which is when the dialog is playing.
Change the DialogPopup’s process mode to “When Paused”, because we need to be able to run our input when the game is paused.
Finally, we’ll need to change our Player’s process mode to “Always”. This means our player will be able to add input to the game even when the game is paused.
A problem will arise if we now play our game because our player will be able to walk away from the NPC when the dialog runs, so to fix this, we need to disable our player’s movement that is executed in their physics_process() function. To do this, we can simply call it at the end of our open() function and set its processing to false! Then in our close function, we just need to set it back to true because if the dialog popup is hidden, we want our player to move again. We’ll also show/hide our cursor.
### DialogPopup.gd
extends CanvasLayer
# Node refs
@onready var animation_player = $"../../AnimationPlayer"
@onready var player = $"../.."
#gets the values of our npc from our NPC scene and sets it in the label values
var npc_name : set = npc_name_set
var message: set = message_set
var response: set = response_set
#reference to NPC
var npc
# ---------------------------- Text values ---------------------------
#sets the npc name with the value received from NPC
func npc_name_set(new_value):
npc_name = new_value
$Dialog/NPC.text = new_value
#sets the message with the value received from NPC
func message_set(new_value):
message = new_value
$Dialog/Message.text = new_value
#sets the response with the value received from NPC
func response_set(new_value):
response = new_value
$Dialog/Response.text = new_value
# ------------------- Processing ---------------------------------
#no input on hidden
func _ready():
set_process_input(false)
#opens the dialog
func open():
get_tree().paused = true
self.visible = true
animation_player.play("typewriter")
player.set_physics_process(false)
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
#closes the dialog
func close():
get_tree().paused = false
self.visible = false
player.set_physics_process(true)
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
#input after animation plays
func _on_animation_player_animation_finished(anim_name):
set_process_input(true)
# ------------------- Dialog -------------------------------------
func _input(event):
if event is InputEventKey:
if event.is_pressed():
if event.keycode == KEY_A:
npc.dialog("A")
elif event.keycode == KEY_B:
npc.dialog("B")
If you run your game, and you run over to your NPC and press “TAB”, your NPC dialog tree should run, and you should be able to accept the quest. If you then run over the quest item and return to your NPC, your NPC will notice that you’ve gotten your quest item and completed the quest. Congratulations, you now have an NPC with a simple quest!
Unfortunately, our quest and dialog are directly tied to our NPC — and in a real game, you would have the NPC, Quest, and Dialog system in their scripts. I’m going to make a separate tutorial series on this, but for now, I just wanted to show you the basics of dialog trees and states.
If you want multiple NPCs, you’ll have to duplicate the NPC scene and script, as well as the Quest Item scene and script — and then just update the values to have unique dialogs and references to your second NPC. I’ll include an example of another NPC in the code reference above.
And there you have it! Now your game has an NPC with a Quest. The rest of the features that we’ll add from here on will be quick to implement. Most of the hard work is done, so now we just need to add a scene-transition ability to transport our player between worlds, and we’ll also give our player the ability to sleep to restore their health and stamina values in the next part! Remember to save your project, and I’ll see you in the next part.
The final source code for this part should look like this.
FULL TUTORIAL
The tutorial series has 23 chapters. I’ll be releasing all of the chapters in sectional daily parts over the next couple of weeks.
If you like this series or want to skip the wait and access the offline, full version of the tutorial series, you can support me by buying the offline booklet for just $4 on Ko-fi!😊
You can find the updated list of the tutorial links for all 23 parts in this series here.