Having Fun Making a Quest Editor This Weekend

So, I made this today. Turns out there’s not a lot of info out there on using GraphEdit or GraphNode. So I watched the few videos there were, and then scoured some very confusing example projects from 3.x. This is what I’ve accomplished so far.

That, and adding it as a plugin that shows up in the top toolbar - which took just as long.

Also, for some reason I cannot drag-and-drop nodes with the code I have. (The first screenshot is me just running the window as a standalone program.)

Anyway, it’s been fun. Thought I’d see if anyone had any aesthetics feedback.

  • I cannot figure out how to change the font color for the node titles.
  • The nodes are using Kenney UI textures for the panels and title. I like the bold colors.
  • For the Quest Failure node I put in an alternate look. When selected, the red outline gets bolder. All the others get brighter when you select them.
  • The colored text for each slot (the colored circles) looks dumb? Should I just leave it white?

So far, none of it is functional beyond making connections, and the colors only accepting same colored connections. Things I still plan to do:

  • Get it to work as a plugin. (Just realized the drag-and-drop code is not in @tool scripts. Easy fix.)
  • Figure out the weird sizing issues I have putting this window in the center dock. (The GraphEdit won’t extend beyond the minimum size, but setting it too high makes it look bad when you shrink the window.)
  • Allow the drag-and-drop of Resources into GraphNodes, just like you can do with an @export variable in the Inspector. So they can be used for collect goals and rewards. (No idea how to do this yet, so if anyone has any ideas, let me know.)
  • Save the quests to Resources. (I thought about just serializing them to JSON, but I want to be able to reference them in the editor once created and attach them to dialogue or other triggers.)
  • See if I can make a .quest file extension for the Resource files. (Last time I tried to get that working, I gave up after a while.)
  • Add undo/redo with the Command Pattern.
  • I’ve been developing this as yet another Plugin/Addon, so at some point I’ll make it available to others.

Anyway, it’s late, I need to get to bed.

9 Likes

I wonder if it’s possible to do something like that in the game — so that players can create their own quests and share them with other players?

1 Like

It absolutely is. In fact, it’s easier to put it in the game than it is in the editor. The first screenshot is in fact, an in-game screenshot as it were.

2 Likes

Hmmm. Seems to me quest editor has a lot in common with a dialog editor :wink:

Looks good :+1:t5:

Cheering you on!

1 Like

It does. I took inspiration from Whiskers, which is a project that Emi recommended in a video 7 years ago.

Once I realized that the Whiskers demo was overriding functions that didn’t start with underscore, and now start with an underscore, I was able to figure out what was going on. I also learned that Control nodes have a built in drag-and-drop functionality that is REALLY powerful.

I’m actually using Nathan Hoad’s Dialogue Manager, and I’m doing this as a precursor to potentially making a GraphNode-based plugin to sit on top of his. Because he has a lot of really great functionality, but I really like being able to see a flowchart.

After some reflection this morning, I think the biggest problem with mine is they have too much empty space in them. The Texture Margins for the Titlebars are 10px, and on the Panel (body) 20 px. I’ve also been reducing the font size. Because I think that they are just too big, and I want to fit more on the screen.

I currently have four basic node types:

  • Start handles the name a quest description, but will also probably have a link to a quest giver at some point. Basically storing general info about the quest. I need to make it so you can only have one in the graph.
  • Goals handle the things you need to do. Right now I’m using a drop-down, but I can save a lot of space by making a different node for each type of goal. You can either have multiple goals hooked together in parallel. Like for example when you need to collect multiple things, or kill X wolves and collect Y bundles of fur. Or they can be chained together, so that you have to do X before you can do Y. In which case I think I may need to add additional quest text to the node, or a separate node for new quest text…
  • Rewards for granting things like XP and gold at the end of a quest. You can currently attach multiple Reward nodes, for an AND effect, but I also might need to add an OR node so that you can let players choose a reward.
  • Failure nodes for if a quest can be failed. Like if the quest-giver dies, time runs out, or something else like you move to a new region of the game and can’t go back.

It’s starting to come together.

5 Likes

Looking good. GraphEdit is neat, people don’t use it enough.

2 Likes

Will each quest have new resource or all be stored under one resource with unique ID’s ?

Did you think about quest giving and returning to refer to specific NPC / item if this be done via some Singleton or separate Inventory and NPC Manager ?

Right now the plan is to make each quest a Resource. After some thinking about it, I’ve decided to make each graph node represented by a linked list, except for the Start node. All the data in that will be inside the Quest Resource. It will have three Arrays for the different node types.

I realized that I could represent AND and OR logic by whether nodes are connected in series or parallel. Though which is which is more difficult. My plan was for Goals in parallel to all be completed in any order, but none to be optional. While in series would mean they need to be done in order. (Pick up the package, deliver the package, return to the quest giver to tell them it was delivered.)

I had originally only thought about how having multiple parallel Reward nodes would allow you to give multiple things at the end of a quest. But then I realized if you put them in series, you could let the player choose between them. That seems to make sense, but is slightly different from Goals.

That then led me to think about the fact that, you can have multiple Failure scenarios (although right now there’s only two options), and I had assumed multiple in parallel would be an OR scenario, but again, that’s different from the others. So why not also allow them in series, and make that the OR scenario?

My plan is to figure out a way to drop a reference to an NPC, Enemy or Item directly into the appropriate fields. Just like you would in the Inspector with an @exported variable. That’s a later step, after getting the serialization and deserialization of Quests to disk and saving/loading working.

I’m specifically designing this to avoid a Manager Anti-Pattern. So while I am ok with a Signal Bus if that is necessary, the Quests themselves are not intended to be “managed” by an outside entity. The idea is you have a Quest resource. It can store state such as not started, completed, and any number of arbitrary other states as needed. My plan was to just have a Dictionary[String, bool] that tracked them.

So let’s say you have a Quest-Giver NPC. It has a Quest resource stored on it. When it gives the Quest to the Player, it just passes that Resource over. Then the Player has a way to store all the Quests it has. (Most likely stored in the UI itself, which is what I do with inventory.) It can display them, and show the status of each Quest, along with the next Goal(s) and potential Reward(s).

Linking to each Resource for an Item, Enemy, and NPC, allows me to pull data for making the UI. For example, here’s my current Item resource code. (I actually don’t have any for NPCs or Enemies yet.)

@icon("uid://c7u8yual0dod3")
class_name Item extends Resource

const DROP_CLOTH_A = preload("uid://c5hnwqtosggdq")

## The display name for this object.
@export var name: String
## The maximum number of these items that can be stacked before a new stack is made in the inventory.
@export var max_stack_size: int = 12
## The texture to use in the inventory.
@export var texture: Texture2D
## The scene to instantiate when this item is dropped into the world.
@export var scene: PackedScene
## The sound to play when the item is removed from the backpack.
@export var drop_sound: AudioStream = DROP_CLOTH_A

And here’s my Item for Sticks:

(Note the item name is all uppercase so that it can be easily localized. Any time it is used in a Label, it will automatically be translated.)

Then the Stick scene:

Where the Pickup component node has a reference to the Resource

…as a file path so as to prevent a recursion error.

So if I have a way to store the Item resource in the Quest I have access to any data I need to display the rewards, goals, etc. with things like the item name, a picture, and even used to add the item directly to the player’s inventory, or drop it to the ground.


As for the Inventory implementation, when you add an item to the Inventory, it looks like this:

func add_item(item: Item, amount: int = 1) -> void:
	var slot: InventorySlot = _get_slot(item)
	
	# If there are no slots available, drop the item.
	if slot == null:
		_drop_item(item)
		return
	
	if slot.item == null:
		slot.item = item
	
	slot.amount += amount
	updated.emit()

And the _get_slot() function:

# Returns the [InventorySlot] containing the passed [Item] is it exists,
# and is not full. Otherwise it returns the next available empty [InventorySlot].
# If all [InventorySlot]s are full, it returns null.
func _get_slot(item: Item) -> InventorySlot:
	for slot: InventorySlot in slots:
		if slot.item == item and slot.amount < item.max_stack_size:
			return slot
	
	for slot: InventorySlot in slots:
		if slot.item == null:
			return slot
	
	return null

The InventorySlot obejct itself also knows what an Item is, and how to store one. (Keep in mind there’s no code yet for persistence yet.)

class_name InventorySlot extends Button

signal drop_item(inventory_slot: InventorySlot)
signal use_item(inventory_slot: InventorySlot)

var item: Item:
	set(value):
		item = value
		
		if item == null:
			texture_rect.hide()
			label.text = ""
			disabled = true
			tooltip_text = ""
		else:
			texture_rect.show()
			texture_rect.texture = item.texture
			disabled = false
			tooltip_text = item.name
var amount: int:
	set(value):
		amount = value
		if amount <= 1:
			label.text = ""
		else:
			label.text = str(amount)
		if amount <= 0:
			amount = 0
			item = null

@onready var texture_rect: TextureRect = %TextureRect
@onready var label: Label = %Label


func _ready() -> void:
	gui_input.connect(_on_gui_input)


func _on_gui_input(event: InputEvent) -> void:
	if event is InputEventMouseButton and event.is_pressed():
		if event.button_index == MOUSE_BUTTON_RIGHT:
			drop_item.emit(self)
		elif event.button_index == MOUSE_BUTTON_LEFT:
			use_item.emit(self)

Ultimately, the idea is I am just passing around Resource objects as data packets. Since they get passed by Reference, this keeps my overhead pretty low. So while it seems like I’m passing a lot of data around, passing by Reference in GDScript actually uses less RAM than passing anything by value. And since every Item is exactly the same, I actually only have one instance in memory at a time for any particular item. If I have 1,000 Sticks in Inventory, then I have 10 stacks of 99 and one stack on 10 - all of which reference the same Item.

3 Likes

So I implemented the Command Pattern today for the Quest editor, as well as the ability to delete and disconnect nodes. Also, the ability to make new QuestGraphNodes.

One problem I ran into was a way to consume the Undo and Redo actions so that when I am in the Quest Editor, I capture those actions. If anyone has any ideas on how I can do that, I’d love to know how. For now you have to select the options from the Edit menu.

There’s not a lot to show off without a video, but I’ve made it so every action you can take can be undone and redone. Except for modifying fields. There’s no way to undo those yet. However, if you delete a node and then undo the delete, it restore all its fields, its position, and additional undos will restore any connections.

For those interested, here’s the code:

Command Abstract Class

@icon("uid://bo17cv28siek3")
@abstract
## An interface class for creating commands that can be executed and undone.
class_name Command extends Resource


## Implement this to define what happens when this command is executed.
@abstract
func execute() -> void


## Implement this to define what happens when this command is undone.
@abstract
func undo() -> void

Create Quest Command

class_name CreateQuestCommand extends Command

const FORCE_READABLE_NAME = true # Helper constant
const QUESTS_16_X_16 = preload("uid://dt03876p01rxq")


var name: String
var container: GraphTabContainer
var graph: QuestGraphEdit
var add_graph_tab: Control


func _init(quest_name: String, graph_tab_container: GraphTabContainer, add_graph_tab: Control) -> void:
	name = quest_name
	container = graph_tab_container
	self.add_graph_tab = add_graph_tab


func execute() -> void:
	graph = QuestGraphEdit.new()
	graph.name = name
	graph.right_disconnects = true
	container.add_child(graph, FORCE_READABLE_NAME)
	container.set_tab_icon(container.get_tab_idx_from_control(graph), QUESTS_16_X_16)
	container.current_tab = container.get_tab_idx_from_control(graph)
	container.move_child(add_graph_tab, -1)


func undo() -> void:
	graph.queue_free()

Creating this greatly simplified the GraphTabContainer code:

@tool
class_name GraphTabContainer extends TabContainer

@onready var add_graph_tab: Control = $AddGraphTab


func _ready() -> void:
	Quests.add_graph_node.connect(_on_add_graph_node)
	tab_clicked.connect(_on_tab_clicked)


func create_tab(tab_name: String = "New Quest") -> void:
	var command := CreateQuestCommand.new(tab_name, self, add_graph_tab)
	Quests.execute.emit(command)


func _on_add_graph_node(scene: PackedScene) -> void:
	get_current_tab_control().add_graph_node(scene)


# Handles the new quest button being pressed.
func _on_tab_clicked(tab: int) -> void:
	if get_tab_control(tab) == add_graph_tab:
		create_tab()

I decided to use the Quests Autoload to pass signals around.

@tool
@icon("uid://cu1tcdypslmjy")
## Quests Autoload
extends Node

signal add_graph_node(node: PackedScene)
signal execute(command: Command)
signal undo()
signal redo()

Right now, you execute a command by calling the execute signal and passing the Command, but I found that a bit unintuitive. Still, for now it works.

The QuestsMainPanel itself actually stores all the commands, instead of the Quests Autoload. I don’t know that this decision matters now, but I had considered a separate queue for each quest, and if I do that, then implementing it this way makes that easier in theory.

@tool
@icon("uid://cu1tcdypslmjy")
class_name QuestsMainPanel extends PanelContainer

var undo_queue: Array[Command]
var redo_queue: Array[Command]


func _ready() -> void:
	Quests.execute.connect(_on_execute_command)
	Quests.undo.connect(_on_undo_command)
	Quests.redo.connect(_on_redo_command)


func _on_execute_command(command: Command) -> void:
	command.execute()
	undo_queue.append(command)


func _on_undo_command() -> void:
	var command: Command = undo_queue.pop_back()
	if command:
		command.undo()
		redo_queue.append(command)


func _on_redo_command() -> void:
	var command: Command = redo_queue.pop_back()
	if command:
		command.execute()
		undo_queue.append(command)


## Doesn't work with embedded version.
#func _unhandled_input(event: InputEvent) -> void:
	#if event.is_action_pressed("ui_undo", false, true):
		#Quests.undo.emit()
		#get_viewport().set_input_as_handled()
	#elif event.is_action_pressed("ui_redo", false, true):
		#Quests.redo.emit()
		#get_viewport().set_input_as_handled()

As you can see, I commented out my Undo/Redo keys because they weren’t working on the embedded version. I guess I could do an is_Editor check.

QuestGraphEdit

Each quest graph edit node has much simplified code now, as all it does really is create Commands and send them off.

@tool
@icon("uid://cu8xqwekwfxve")
class_name QuestGraphEdit extends GraphEdit

const FORCE_READABLE_NAME = true # Helper for code readbility

# The offset for the first node placed.
var initial_position := Vector2(20, 20)
# The offset for each new node added.
var node_offset : = Vector2(20, 40)
# How many nodes have been added.
var node_index: int = 0


func _ready() -> void:
	connection_request.connect(_on_connection_request)
	disconnection_request.connect(_on_disconnection_request)


# Determines if data can be dropped on this node. We only want to allow
# GraphNodes.
func _can_drop_data(at_position: Vector2, data: Variant) -> bool:
	return true


# When a GraphNode is dropped, drop it.
func _drop_data(at_position: Vector2, data: Variant) -> void:
	var command: Command = AddQuestNodeCommand.new(data, self, at_position - Vector2(0, 30))
	Quests.execute.emit(command)


func _on_connection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
	var command := ConnectQuestNodeCommand.new(self, from_node, from_port, to_node, to_port)
	Quests.execute.emit(command)


func _on_disconnection_request(from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
	var command := DisconnectQuestNodeCommand.new(self, from_node, from_port, to_node, to_port)
	Quests.execute.emit(command)


func add_graph_node(scene: PackedScene) -> void:
	var command: Command = AddQuestNodeCommand.new(scene, self, initial_position + (node_index * node_offset))
	Quests.execute.emit(command)

AddQuestNodeCommand

class_name AddQuestNodeCommand extends Command

const FORCE_READABLE_NAME = true # Helper constant

var scene: PackedScene
var quest_graph_edit: QuestGraphEdit
var position_offset: Vector2
var graph_node: GraphNode


func _init(scene: PackedScene, quest_graph_edit: QuestGraphEdit, offset: Vector2) -> void:
	self.scene = scene
	self.quest_graph_edit = quest_graph_edit
	position_offset = offset


func execute() -> void:
	graph_node = scene.instantiate()
	graph_node.position_offset += position_offset
	quest_graph_edit.add_child(graph_node, FORCE_READABLE_NAME)
	quest_graph_edit.node_index += 1


func undo() -> void:
	position_offset = graph_node.position_offset
	graph_node.queue_free()
	quest_graph_edit.node_index -= 1

ConnectQuestNodeCommand

class_name ConnectQuestNodeCommand extends Command

var quest_graph_edit: QuestGraphEdit
var from_node: StringName
var from_port: int
var to_node: StringName
var to_port: int


func _init(quest_graph_edit: QuestGraphEdit, from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
	self.quest_graph_edit = quest_graph_edit
	self.from_node = from_node
	self.from_port = from_port
	self.to_node = to_node
	self.to_port = to_port


func execute() -> void:
	quest_graph_edit.connect_node(from_node, from_port, to_node, to_port)


func undo() -> void:
	quest_graph_edit.disconnect_node(from_node, from_port, to_node, to_port)

DisconnectQuestNodeCommand

class_name DisconnectQuestNodeCommand extends Command

var quest_graph_edit: QuestGraphEdit
var from_node: StringName
var from_port: int
var to_node: StringName
var to_port: int


func _init(quest_graph_edit: QuestGraphEdit, from_node: StringName, from_port: int, to_node: StringName, to_port: int) -> void:
	self.quest_graph_edit = quest_graph_edit
	self.from_node = from_node
	self.from_port = from_port
	self.to_node = to_node
	self.to_port = to_port


func execute() -> void:
	quest_graph_edit.disconnect_node(from_node, from_port, to_node, to_port)


func undo() -> void:
	quest_graph_edit.connect_node(from_node, from_port, to_node, to_port)

QuestGraphNode

I finally had to add a base script to my QuestGraphNodes so I could delete them.

class_name QuestGraphNode extends GraphNode


func _ready() -> void:
	delete_request.connect(_on_delete_request)


func _input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_graph_delete") and selected:
		delete_request.emit()


func _on_delete_request() -> void:
	var command := DeleteQuestNodeCommand.new(self, get_parent())
	Quests.execute.emit(command)

I went ahead and jumped through a few hoops, just in case there’s some reason I might have wanted to use the delete_request signal in the future. Though TBH, I dunno if I will.

DeleteQuestNodeCommand

This is where things got interesting. If I actually delete the node, then I run into the problem of not having a reference to the scene that created the node. I thought about adding that into each node definition, but it was an easy failure point as I make more. It also meant that all the values got set back to their defaults. So, I decided just to reparent the node to the Quests autoload and hide them. Then pulling them back is easy.

After that, I noticed that the connections weren’t restoring. So, I just loop through them and run a DisconnectQuestNodeCommand on each one. This automatically puts them in the Undo queue, right before the delete command, allowing the user to keep undoing to get them back.

class_name DeleteQuestNodeCommand extends Command

var quest_graph_edit: QuestGraphEdit
var position_offset: Vector2
var graph_node: GraphNode


func _init(graph_node: GraphNode, quest_graph_edit: QuestGraphEdit) -> void:
	self.graph_node = graph_node
	self.quest_graph_edit = quest_graph_edit


func execute() -> void:
	for connection in quest_graph_edit.get_connection_list_from_node(graph_node.name):
		var command := DisconnectQuestNodeCommand.new(quest_graph_edit, connection.from_node, connection.from_port, connection.to_node, connection.to_port)
		Quests.execute.emit(command)
	
	graph_node.selected = false
	graph_node.reparent(Quests)
	graph_node.hide()
	quest_graph_edit.node_index -= 1


func undo() -> void:
	graph_node.reparent(quest_graph_edit)
	graph_node.show()
	quest_graph_edit.node_index += 1
3 Likes

I got the Undo (Ctrl + Z) and Redo (Ctrl + Shift + Z) commands to work by using _input(). I had tried _gui_input() and _unhandled_input(), but not _input(). I figured that out as I was creating the post to ask for help. :slight_smile:

func _input(event: InputEvent) -> void:
	if event.is_action_pressed("ui_undo", false, true):
		Quests.undo.emit()
		get_viewport().set_input_as_handled()
	elif event.is_action_pressed("ui_redo", false, true):
		Quests.redo.emit()
		get_viewport().set_input_as_handled()
4 Likes