We have our data classes created and
are now able to remove our choices. This means that we are finally ready to start saving our Graph. Of course, if you haven't yet
created the necessary classes for it, make sure you watch that part first. Before we go ahead and start making our saving system, it's better for us to first understand how is it going to work. We've already talked on how the data is
going to turn into files and folders. What we need to know now is how are we going
to iterate through the necessary Graph elements in a way that we can save them into those files and folders. In our final folder structure, there
will be static and dynamic folders. Static folders are those folders that will
always exist regardless of the Graph elements, such is the case of the "Global" and the "Groups" folders. Dynamic folders are those folders that
will depend on the Graph elements, in this case, it will be a folder for each Group in the Graph, as they will likely have different names
throughout the different Graphs. So we'll start our Save system by creating those static folders. With the folders created, we can then focus
on getting the elements from the Graph and separate them into a nodes and groups list, as that's all we're going to need to be able to save. With our elements in their respective lists,
we'll be able to start saving them. To do that, we'll of course need to
first create our Graph Scriptable Object, which will hold all the necessary
data to both save and load the Graph, as well as our Dialogue Container Scriptable Object, which will hold all the necessary data
to create our custom inspector later on. We'll need to save both our groups and
our nodes to these two scriptable objects. So with those assets created, we will
then start by saving our groups first. They will be saved to the Graph SO, the Container
SO and to their corresponding folders and files. After saving our groups, we'll have to save our nodes. They will be saved to the Graph SO, the
Container SO and their corresponding files. When we finish saving our nodes, we'll actually need to update them again
to be able to update their connections. We'll talk about on why we need to do that
only after we save the nodes when we get there. After all of our groups and nodes are saved and both the graph and the container scriptable objects
have their data updated, we'll need to finalize the system by removing the unused group and node files and folders from our Project. That's the base of our Saving System. And to be able to do all of this, we'll
be creating our own utility class. So in Unity, in our "DialogueSystem" Editor Folder,
go to the "Utilities" folder and create a new file to which I'll name "DSIOUtility". "IO" stands for "Input" and "Output", as this class will be used to both Save and Load our Graph. Inside, feel free to remove the default methods
as well as the "MonoBehaviour" inheritance. I'll make it part of the "Utilities" subnamespace. Much like our other Utility classes,
I'll make this class be "static". We'll start by creating our "Save" method. So type in "public static void Save". I'll make this method be part of
a region named "Save Methods". The first thing we need to do is to create our static folders. We'll need to create them in two different places: The Editor Folder, in which we'll be creating a
"Graphs" folder for our Graph Scriptable Object and the non-Editor DialogueSystem Folder,
in which we'll have multiple static folders. So call in a new method to which I'll name "CreateStaticFolders". You can name it "Default" or something else if you prefer. I'll make it part of a new region named "Creation Methods". To create our folders, we'll be using a method named "CreateFolder" from the Unity "AssetDatabase" class. This method simply accepts the path of
where we want to create the folder in, as well as the name of the folder we want to create. However, it does require that every folder
of the path we pass in already exists, so for that particular reason, we'll need to
make sure we create the previous folders as well. The only thing we need to keep in mind is that
we don't really want to create a folder that already exists. Thankfully, the same "AssetDatabase" class
also provides a method named "IsValidFolder", which as the name states, it tells us if a
folder with the given name already exists. So start the method by typing in "if (AssetDatabase)", and we'll need to import the "UnityEditor" namespace. Then, back to our if statement, type in ".IsValidFolder()". We'll first be creating our Editor "Graphs" folder, but because our system already comes
in the "Assets/Editor/DialogueSystem" folder as it won't work otherwise, we'll only need to care about
checking for the "Graphs" folder, so type in "Assets/Editor/DialogueSystem/Graphs". You can check for the "DialogueSystem"
folder as well if you prefer that, but I'll assume my system is always in this folder. Do note that we should not type in a "/" at the end of the path, as otherwise the method will count it as an "invalid" folder. Inside the if statement, simply "return;",
as we don't want to create it again if it already exists. Under our if statement, we'll create the folder by typing in "AssetDatabase.CreateFolder()" and as the first argument we'll pass in
"Assets/Editor/DialogueSystem", again, without the "/" at the end, and then "Graphs" as the second
parameter for the name of the folder. We'll be wanting to do this for multiple folders, so we should create a method for us
to call this code multiple times. So select the code, and if you are in Visual Studio, simply press "Alt + Enter" and choose "Extract method". I'll name the method "CreateFolder". I'll make it part of a new region named "Utility Methods". The method we've created automatically
did not create parameters for us, so we need to make sure we add in a "string"
parameter, to which I'll name "path", and a second parameter of type "string"
as well to which I'll name "folderName". Next, copy the "AssetDatabase.CreateFolder" strings and pass them in to our own "CreateFolder" method call. When that's done, back to the "AssetDatabase.CreateFolder" method, make sure it uses our new parameters instead. We'll need to do the same for our "IsValidFolder",
so make it an interpolated string, in which you can do it by adding a
dollar sign ($) before the string, and then swap its contents with "path"
between curly brackets ({path}), which makes the string get the variable value, and then add a "/" and "folderName" between
curly brackets ({folderName}) as well. With that done, we can now reuse the method
to create as many folders as we want. So back to our "CreateStaticFolders" method, we'll create our non-Editor folders by typing in "CreateFolder()" at the path of "Assets",
and with the folder name of "DialogueSystem". We are now safe to create our "Dialogues" folder, so type in "CreateFolder("Assets/DialogueSystem", "Dialogues")" Every non-Editor folder will be inside of this "Dialogues" folder. This folder was simply created for organization purposes. What we need to do next is to
create our Graph File Name folder, however, we don't really have a way
to know the Graph File Name here, as it is written in our Toolbar file name text field. Because we'll be calling the "Save" method
whenever we press on the "Save" button, we can simply create an initialization
method that gets called before we save. So above our "Save" method, create a new "public
static" method to which I'll name "Initialize". As a parameter, we'll be accepting a
"string" to which I'll name "graphName". Of course, to save this name somewhere,
we'll need to create a global variable. So above, create a new "private static" variable of type "string", and I'll name it "graphFileName". Then, simply assign the value of "graphName"
into this variable in our "Initialize" method. As we start creating and saving files, we'll
need to always type in the full folder path, which will almost always be our non-Editor folder path. So to help us out with that, we'll be creating a variable that
will hold that non-Editor folder path, so that we don't need to type it in every time we need it. So create a new "private static string"
and I'll name it "containerFolderPath". I simply named it "container" because our main
Runtime Part Scriptable Object is named "DialogueContainer". With the variable created, in our "Initialize" method, type in "containerFolderPath" equals to a new
interpolated string ($"") with the path of "Assets/DialogueSystem/Dialogues/{graphFileName}". We now have the container folder path and the graph file name, so back to our "CreateStaticFolders" method, we'll
create our Graph File Name folder by typing in "CreateFolder("Assets/DialogueSystem/Dialogues", graphFileName)" If you remember from our files and folders structure, we'll be having our "Global" folder, which
will be used for our ungrouped dialogues, and our "Groups" folder, which will be used
both for our groups and their dialogues, which are the grouped dialogues. So type in "CreateFolder(containerFolderPath, "Global"). Do the same but for our "Groups" folder. Then, inside of our "Global" folder, we can create the "Dialogues" folder right away, as it's a static folder, so type in
"CreateFolder($"{containerFolderPath}/Global", "Dialogues"). We have all of our static methods created. With that out of the way, we can start by getting our graph view elements and placing them in their own lists. So in our "Save" method, call in a new method
to which I'll name "GetElementsFromGraphView". I'll make it part of a new region named "Fetch Methods". I don't really know a good name for this region
so feel free to name it however you'd like. Because we'll need to iterate through the graph elements, we'll be needing reference to the Graph View,
so above in our variables, create a new "private static" variable of type
"DSGraphView", to which I'll name "graphView". Don't forget to import the "Windows" namespace. Then, pass in a new parameter to our "Initialize" method of type "DSGraphView", and I'll name it "dsGraphView". When that's done, simply set the "graphView"
variable value to this new parameter. Back to our "GetElementsFromGraphView" method, we are now able to iterate through our Graph Elements. So type in "graphView.graphElements", and
because this variable is of type "UQueryState", we'll need to use its "ForEach" method, so type in ".ForEach()" and pass in "graphElement" as the callback parameter name. Inside the callback we'll simply have to
cast the element into a node or a group depending on its type and add it to the
corresponding "groups" or "nodes" list. And to do that, we'll first need to create them. Start by importing the "Elements" namespace. Then, in our variables, create a new
"private static List<DSGroup>" and I'll name it "groups". Do the same for our nodes, so
"private static List<DSNode>" and I'll name it "nodes". When that's done, initialize both lists in the "Initialize" method. Back in our "GetElementsFromGraphView" method, we'll start by checking if the element is of type DSNode,
so type in "if (graphElement is DSNode)". I'll use the pattern matching here so
I'll type in "node" after our "DSNode". Then, we simply pass this node into the "nodes" list, so "nodes.Add(node)". Don't forget to "return;". For our groups, we'll be using the "GetType()" instead so create a new variable outside of the loop of type "Type", to which I'll name "groupType"
and assign the value of "typeof(DSGroup)". Then, inside of our loop, type in
"if (graphElement.GetType() == groupType)" and inside we'll create a new variable of type "DSGroup", to which I'll name "group",
and assign the casted "graphElement" variable to it. When that's done, add the group to the
"groups" list through the ".Add()" method. At the end, simply "return;". We now have our elements in their corresponding lists, so we can iterate through them to create the necessary assets. We'll start by creating both the
Graph and the Container Scriptable Objects first as they will be necessary
in our "SaveGroups" and "SaveNodes" methods. We'll of course be creating assets multiple times, so we'll need to create our own method
to be able to reuse it properly. To do that, call in "CreateAsset()". I'll make it part of the "Utility Methods" region. To create our assets,
we'll be using the ScriptableObject "CreateInstance" method that Unity provides to instantiate our Scriptable Object. With that SO instance, we'll then use
the AssetDatabase "CreateAsset" method to create an actual assetin our Project. Of course, we'll need to know what type of
Scriptable Object we'll want to instantiate, and to do that, we'll be accepting a generic type in our method. We can do that by typing in "<T>" after the method name. This simply means we can now pass in any
type for this method between angle brackets, which can then be used as an actual
type by using the "T" as the type. You can of course name the generic type whatever you want, I've named it "T" because that's
a common name for generic values. However, we'll be using this "T" type
for our Scriptable Object instance. That's a problem because Unity's
"CreateInstance" method needs a type that's of type "ScriptableObject",
so we'll have to somehow tell our method that our "T" type can be any type, as long as that
type inherits from the "ScriptableObject" class. Thankfully, we can easily do that by typing in "where T : ScriptableObject", right after our method signature. If you check the "CreateInstance" method, this is what it uses. Because we'll be returning the created asset from the method, we need to make sure to update our
"void" return type with "T" instead. We can now create any type of Scriptable Object, so create a new variable of type "T" to which I'll name "asset". We'll be instantiating the SO to this variable so
type in "= to ScriptableObject.CreateInstance()", passing in "T" between angle brackets. We now have our SO instance, so we just need to make sure an asset of
its type is created in our Project folder. To do that, type in "AssetDatabase.CreateAsset()"
and pass in "asset" for the first parameter. For the second parameter, we need to send in the
path of where we want to create the asset in, together with the name of the asset file and its file type. To do that, we'll be adding in
two new parameters in our method. The first one will be of type "string" and I'll name it "path". The second one will be of type "string"
as well and I'll name it "assetName". With that done, we'll be creating a new variable
of type "string" to which I'll name "fullPath". Then, assign it an interpolated string ($"")
with the path of "{path}/{assetName}.asset". The ".asset" is simply the file type that assets use. When that's done, pass this variable
as our "CreateAsset" second parameter. Our SO is instantiated and its asset is created,
so we can now simply return it at the end. There is one last thing we need to keep in mind though. If the asset exists, we can simply
load it instead of creating a new one. This is not really necessary, but makes
it so we don't instantiate the SO again nor remove and create a new asset. We'll be using the AssetDatabase
"LoadAssetAtPath" method to do this. So remove the type of where we declared our
"asset" variable and declare it again above. Then, assign to this variable
"AssetDatabase.LoadAssetAtPath()" and pass in "fullPath" as the parameter. We do need to send in the type of the asset we want to load, as this method tries to get the first asset of a type in the path we've sent in, which is also why we've sent
in the full path with the asset name included. We can do that in two different ways: We either pass in "typeof(T)" as the second parameter, in which we'll then need to cast the returned value to "T", or pass in "<T>" between angle brackets after the
method name, which automatically casts it to "T". This method accepts both, but I'll go with the second one, so type in "<T>" between angle brackets. Of course, right now, it's always loading and
then instantiating and creating the asset again, so we'll add the other two lines inside an if
statement that checks "if (asset == null)", as that's what the method returns if the asset can't be loaded. With that method done, we can now create a
scriptable object asset anywhere we want. In our "Save" method, we'll start by creating
our Graph and Container Scriptable Objects. To do that, we'll need to first import both the "Data.Save" and the "ScriptableObjects" namespaces. When that's done, back in our "Save" method, create a new variable before our existing "CreateAsset" call of type "DSGraphSaveDataSO", to which I'll name "graphData". Then, pass in "<DSGraphSaveDataSO>"
as the type of the asset. For our path, pass in "Assets/Editor/DialogueSystem/Graphs", again,
do not pass in "/" at the end of the path. For our asset name, we'll pass in an interpolated
string ($"") with the "{graphFileName}" variable and "Graph" after the variable. This is simply to attach "Graph" to the end of the file name, if you don't want that, feel free to remove the whole interpolated string and simply pass in "graphFileName". We'll then initialize the graph data
by typing in "graphData.Initialize()" and pass in "graphFileName" as the file name argument. With this, our Graph SO is now created
and its variables are initialized. Next is our dialogue container, so type in "DSDialogueContainerSO"
and I'll name it "dialogueContainer". Then, create its asset by typing in
"CreateAsset<DSDialogueContainerSO>" and because this will be our Runtime Part SO, we can use the "containerFolderPath" variable
we've created before for our path. Then, we pass in "graphFileName" for the asset name. When that's done, initialize it by typing
in "dialogueContainer.Initialize()" and pass in "graphFileName" as the file name argument. We can now start saving our groups and nodes. Of course, as we've previously said, we'll start with our groups. To do that, call in a new method
we'll be creating named "SaveGroups". Pass the "graphData" and the
"dialogueContainer" as arguments. I'll place it in a new region named "Groups"
inside of the "Save Methods" region. We'll be iterating through the groups list and
save each one to the graph SO and then create their scriptable objects, saving them
to the dialogue container as well. So type in "foreach (DSGroup group in groups)" and we'll be calling in a new
method named "SaveGroupToGraph", in which we'll pass in the "group" and the "graphData". For our graph we simply need to
save our group data into a variable and add it to the graph SO groups variable, so type in "DSGroupSaveData"
and I'll name it "groupData" and call its constructor. We'll be setting its values through the Object Initializer, so "ID = group.ID", "Name = group.title", and "Position = group.GetPosition().position", as the previous gets the "Rect". Then, add this variable to the graph data groups list by typing in "graphData.Groups.Add(groupData)". That's it for the graph data so for
our dialogue container and SO assets, back in our "SaveGroups" loop, call in another method we'll create
named "SaveGroupToScriptableObject". Pass in "group" and "dialogueContainer" as arguments. I'll place it under our "SaveGroupToGraph" method. We'll be taking care about the group folder, SO and dialogue container groups dictionary element here. We'll start by creating our Group folder,
in which we need to know the group name, so create a new variable of type
"string" and I'll name it "groupName". Then, assign the "group.title" variable value into it. To create the folder, simply type in
"CreateFolder($"{containerFolderPath}/Groups", groupName)" We'll also be creating this group folder
"Dialogues" folder right away so that we don't need to do it
when iterating through the nodes, so type in "CreateFolder($"{containerFolderPath}/Groups/{groupName}", "Dialogues"), With our folders done, we can now create our group asset file. To do that, type in "DSDialogueGroupSO",
to which I'll name "dialogueGroup" and assign to it "CreateAsset<DSDialogueGroupSO>()". For the path, we'll pass in the same path as our second "CreateFolder" path so copy it up and paste it here. For the asset name, we'll pass in the "groupName" variable. With our group asset created, initialize it by typing in "dialogueGroup.Initialize(groupName)". All that's left is to add the group to the
dialogue container groups dictionary so type in "dialogueContainer.DialogueGroups.Add()" and pass in "dialogueGroup" for the key, and a new empty list of "DSDialogueSO" for the value. The dialogues for this group will be added as we are iterating through the nodes whenever they belong to this group. That's it for our group. However, there is one thing we need to keep in mind. We've been creating assets, but we
haven't really been saving them. Whenever we update the data of an asset, we
need to tell Unity to set that asset as "dirty", simply meaning this asset has
been updated and needs to saved. But for these assets to be saved after
telling Unity to set them as "dirty", we supposedly need to manually save the project. Thankfully, the "AssetDatabase" class provides us both with the "SaveAssets" method, which saves the changes from code, without
us needing to manually save the project, and the "Refresh" method, which
then imports those changed assets. I said "supposedly" because I've recently
tried it with the "dirty" part only and the assets were created, but I also
remember when I first made this system that not having the 3 method calls
would sometimes not create the assets, so we'll add them all to be completely safe. So to do that, call in a new method
we'll be creating named "SaveAsset". We'll be passing in our "dialogueGroup" into this method. I'll make it part of the "Utility Methods" region. To set our asset as "dirty", we'll need to
use the EditorUtility "SetDirty" method. However, that method requires an argument of type "Object", so make sure you update our "SaveAsset" parameter
to be of type "UnityEngine.Object" instead. We'll be specifying "UnityEngine" here because
the "System" also has an "Object" type. Update the parameter name to be "asset" as well. Then, call in "EditorUtility.SetDirty(asset)". Once that's done, call in "AssetDatabase.SaveAsset()" and "AssetDatabase.Refresh()", just under that. Our "SaveAsset" method is done so all that's left
is to call it wherever we created our assets. That's already done in our
"SaveGroupToScriptableObject" method so back in our "Save" method,
we'll need to do it for both of our assets, so under everything else type in "SaveAsset(graphData)" and "SaveAsset(dialogueContainer)". We're now saving all of our current
assets and our group saving is also done. With that, it's time for us to start saving our nodes, so right under our "SaveGroups" method, call
in a new method we'll create named "SaveNodes". Pass in "graphData" and "dialogueContainer" as arguments. I'll place it in a new region named
"Nodes" right under our "Groups" region. We'll start by looping through our
nodes, much like we did for our groups, so type in "foreach (DSNode node in nodes)". Inside the loop, call in a new method
we'll create named "SaveNodeToGraph()" and pass in "node" as the first
argument and "graphData" as the second. We'll do what we did in our group but for our node data, so create a new variable of type "DSNodeSaveData", to which I'll name "nodeData" and call its
constructor together with the Object Initializer. We'll be setting all the variables. If you are using Visual Studio,
you can see them by pressing "Ctrl + Space". We'll set the "ID" first to be "node.ID". Then, the "Name" to be "node.DialogueName", the "Choices" to be "node.Choices", the "Text" to be "node.Text", the "GroupID" to be "node.Group.ID", the "DialogueType" to be "node.DialogueType", and the "Position" to be "node.GetPosition().position". We can now add our data to the graph nodes list by typing in "graphData.Nodes.Add(nodeData)". Our data is almost done, but we
currently have three slight problems. The first one is regarding the node group ID. This is simply because our "Group" variable can be "null", which means it will throw an error if we were to access
its "ID" variable when that's the case. Thankfully, we can use something handy called
"null-condition operator" for member access. This is as simple as adding a question mark
(?) after our "Group" and before our dot (.). If the "Group" is null, then it will simply return
"null" without trying to access the "ID" variable. If the "Group" isn't null, then it will
simply return the "ID" variable value. The second one are our choices. Right now, we're sending in our "node.Choices" variable. While this might be fine at first glance, we need
to keep in mind that we're sending in a "List". This is a problem because a list is
of "reference type", as it is a class. This simply means that the moment we update
one of the choices in our graph nodes, our Scriptable Object choice data will also be updated, as it's referencing the node choices list. We of course don't want that,
and want them to be independent, as changes should only happen whenever we save the Graph. To fix this problem, we'll have to clone our list in a way that the clone doesn't reference the original list. There are two ways we could do that: The first one is by passing our list into the new list constructor. This will clone the list without referencing it. However, that is only true for primitive types, like strings or integers, as they are "value types". For cases like ours, where we are using "reference types", we'll need to iterate through the original list,
create a new variable of the same type, set its values to be the same and then
add the variable into the new list. So above our "nodeData" variable, create a new "List<DSChoiceSaveData>", to which I'll name "choices" and initialize it. Then, iterate through the node choices by typing in "foreach (DSChoiceSaveData choice in node.Choices)". Inside this loop, create a new variable of type "DSChoiceSaveData", to which I'll name "choiceData" and call its constructor together with the Object Initializer. We'll then set the "Text" to be "choice.Text" and the "NodeID" to be "choice.NodeID". These are both "strings", which are "value types", so we won't have any problems regarding references. When that's done, add the "choiceData" to the
"choices" list through the ".Add()" method. We now have our cloned list so we can swap our
"node.choices" below with "choices" instead. With that done, our data choices should now
be independent of the graph node choices. The third one is regarding our node "Text.". Because we haven't added a callback to our node
text TextField, it will always remain "Text", no matter how much of the text we change. So go to our "DSNode" script, and in the
"Draw" method, in our "textTextField", pass in "null" for the label and then a new callback, to which I'll name "callback", and simply set the "Text" variable to be equal to "callback.newValue". Our node text will now be correctly updated. With that done, we can go back to our "DSIOUtility" class. We do also need to do the same for our node text,
as its text field currently holds no callback, so head to the "DSMultipleChoiceNode" script
and at the bottom in our "choiceTextField", we'll be adding a new callback by passing in "null" to the label, and a new callback named "callback". Then, update the "choiceData.Text"
to be "callback.newValue". Remember that this choice data is a
reference to the Choices list element, so updating this will update the Choices list element as well. We'll now save our node as scriptable objects,
so under our "SaveNodeToGraph" method call, call in a new method we'll create
named "SaveNodeToScriptableObject" and pass in "node" and "dialogueContainer" as arguments. I'll place this method under our "SaveNodeToGraph" method. This will be a bit different from the group
method as we'll need to create the node into a different path and a different dialogue container variable depending on whether the node
is an ungrouped node or a grouped node. We'll do that by creating the SO variable and only initialize it when we know what type of node it is. So type in "DSDialogueSO" and I'll name it "dialogue". Then, we'll do our if check statement
to see if this node is a grouped node, so type in "if (node.Group != null)" and inside we'll need to create this asset
in the correct Group folder, so type in "dialogue = CreateAsset<DSDialogueSO>()" and in an interpolated string ($""), we'll give it the path of {containerFolderPath}/Groups/{node.Group.title}/Dialogues" passing in "node.DialogueName" as the asset name. All that's left in this if statement is to add the dialogue to the dialogue container dialogue groups dictionary, so type in
"dialogueContainer.DialogueGroups.Add()". However, you might have noticed we have a problem here. The problem is that the key of this dictionary
is a reference to the group scriptable object, which we currently have no way of getting. We'll fix that in a simple way: We'll created a dictionary of every created
group SO in which the key will be the group ID, so that we can use the node group
variable to get the element, and the value will be the actual scriptable object. So in our variables above, create a new
"private static Dictionary", as we don't really need it to be "SerializableDictionary",
and I'll make the key type be "string", and the value type to be "DSDialogueGroupSO". I'll name it "createdDialogueGroups". Then, initialize this dictionary in the "Initialize" method. We'll now add our created dialogue group
scriptable objects to this dictionary so in our "SaveGroupToScriptableObject" method, right after we initialize the dialogue group, type in "createdDialogueGroups.Add(group.ID, dialogueGroup)" With that done, back to our
"SaveNodeToScriptableObject" method, we can add the correct key in our ".Add()" method by typing in "createdDialogueGroups[node.Group.ID]", which gets
the dialogue group SO with the given group ID. We should of course add our node into
this element list, so pass in "dialogue". You might have noticed we have a few problems here. The first one is that this dictionary value is a list. The second one is that this is not
how you add a value to that list, but instead, how you add a new element to the dictionary. The third problem is that we need to know if the
dictionary already has an element with this key. This is so we can know whether we should add
a new element or just update the existing one, which would be adding our dialogue to the existing element list. In this dictionary case, we already know it
has an element with this group ID as the key, as we've previously iterated through
our groups and added an element into it. However, just so we don't really need to think about that, we'll be creating our own Utility method
that takes care of that logic for us. So in Unity, in our non-Editor "Utilities" folder, create a new C# Script to which I'll name "CollectionUtility". Inside, remove the default methods together
with the "MonoBehaviour" inheritance. I'll make it part of the "Utilities" subnamespace. I'll make the class static as well. For our method, we'll create a new "public
static void" method to which I'll call "AddItem". This method will accept two generic types: "<K, and V>". The "K" will be the dictionary key type, while the "V" will be the list
type, which is the dictionary value. As parameters, pass in "this SerializableDictionary", as we'll be using this method in our serializable dictionaries, with the type of "K" and the value of "List<V>". I'll name it "serializableDictionary". As the second parameter, we'll have "K key". As the third, we'll have "V value". We'll start by checking if the dictionary
contains an element with the key, so type in
"if (serializableDictionary.ContainsKey(key))" Then, add the value to the list by typing in "serializableDictionary[key].Add(value)". At the end, simply "return;". If our dictionary doesn't contain this
key, we'll need to add a new element to it, in which the element list will only have one value, which will be the value we've passed in. So type in
"serializableDictionary.Add(key, new List<V>() { value })" This is called "Collection Initializer", and allows you to initialize a list with certain values in it. You don't really need to type in the parenthesis
if you don't want to, as they are optional. With this method done, we now don't need to care whether the dictionary already has an element or not, as it takes care of that logic for us. So back in our "DSIOUtility" script, swap
the "Add" method with "AddItem" instead. We don't need to import the "Utilities" namespace as our "DSIOUtility" is already part of that namespace. With that done, we can now do
the ungrouped node if statement. Of course, we can easily do that by
typing in "else" instead of a new "if". In here, we'll create the asset in the "Global/Dialogues" folder, so type in "dialogue = CreateAsset<DSDialogueSO>()" and pass in the path in an interpolated string ($"") of
"{containerFolderPath}/Global/Dialogues", with "node.DialogueName" as the asset name. Then, we simply add it to the container
ungrouped dialogues list by typing in "dialogueContainer.UngroupedDialogues.Add(dialogue)". Our node asset is now created whether it
is an ungrouped node or a grouped node. So with that out of our way, under our if else
statement, initialize the dialogue by typing in "dialogue.Initialize()". As arguments, we'll pass in "node.DialogueName", "node.Text", "new List<DSDialogueChoiceData>" for now, "node.DialogueType" and "false" for now as well. There are two things we should keep in mind: The first one is that our node choice data and
our dialogue choice data use different classes. This is because in our dialogue choice data,
our node, or "NextDialogue", is an actual reference to the dialogue scriptable object, which in our node choice data, it's simply its "ID". Because of that, we'll need to convert our node choices list
into a suitable dialogue choices list. The second one is that we don't yet have a way
to know whether a node is a starting node or not. We'll be creating a method in our node
class that returns us that information. Let's start with the first one, so swap the list initialization with a new method we'll create named
"ConvertNodeChoicesToDialogueChoices" and pass in "node.Choices" as an argument. Rename the "choices" parameter to be "nodeChoices" instead. Inside, we'll have to iterate through the node choices and add them to a new list with the correct type. So type in "List<DSDialogueChoiceData>", to which I'll name "dialogueChoices" and initialize it. Then, type in
"foreach (DSChoiceSaveData nodeChoice in nodeChoices)". Inside of our loop, we'll
create a new variable of type "DSDialogueChoiceData", to which I'll name "choiceData"
and call its constructor together with the Object Initializer. The only thing we need to pass in here is the "Text", to which we'll assign to "nodeChoice.Text". We won't pass in the next node reference here as we'll be doing that when updating the node connections. Then, add the "choiceData" to the
"dialogueChoices" list using the ".Add()" method. At the end, simply return the "dialogueChoices" list. With our convertion method done,
we can create our node method. So go to our "DSNode" script and
in the "Utility Methods" region, just above our Error Style methods, create
a new "public" method that returns "bool" and to which I'll name "IsStartingNode". To know if our node is a starting node is quite simple: If the node does not have a connection in
its input port, then it is a starting node. So all we need to do is to get that input
port and return its "connected" variable. To do that, type in
"Port inputPort = inputContainer.Children()" and we'll be using the "Linq" namespace here
for a method named "First", which simply returns the first element of an IEnumerable, so type in ".First()" and import the namespace. We can't really just get the first index because
"Children()" returns an "IEnumerable" as well. Note that "First" will throw an
exception if it can't find any element. If you did want it to return "null" instead, you
would need to use the "FirstOrDefault" method. However, because we know there is always
an input port in our input container, we are safe using the "First" method. Then, simply return "inputPort.connected". Of course, our method should return
true if the node is a starting node, which means the node isn't connected, so we
need to add a not (!) operator at the beginning. When that's done, go back to our "DSIOUtility" script and swap our "false" value with
"node.IsStartingNode()" instead. All that's left for this method is to save the
dialogue asset so type in "SaveAsset(dialogue)". We are done saving our groups and nodes to both the graph and the respective runtime part scriptable objects. However, there are a few things left for us to do: We need to set the dialogue choice connections to reference their respective dialogue scriptable objects as well as remove the unused
groups and nodes files and folders. We'll start by updating our choices connections. To do that, in our "SaveNodes" method, right after the loop, call in a new method we'll be creating
named "UpdateDialoguesChoicesConnections". I'll place it under our
"ConvertNodeChoicesToDialogueChoices" method. If you remember correctly, I've said I would explain why we
are only saving our connections now. When I first started making this system
I've tried the following approach: When iterating through the nodes, if
the current node had a connection, then I would create that connected node first. This is simply because when setting the connections, if the next node wasn't created yet,
we wouldn't have a reference to it. This was fine because we would just
basically go until the last connected node and then create them in the opposite order. Of course, if we iterated through a node we had already created, we would simply skip that node and its connections. The problem with this approach was when the
next node was connected to the previous node. This would get into an infinite loop, as the
nodes would always have a next connection. There are possible ways of fixing this problem, but instead I've decided to just
go with a simpler approach instead: Our first node iteration is to create their
respective dialogue scriptable objects. As we are creating those scriptable objects,
we'll also be adding them into a dictionary, much like our "createdDialogueGroups" dictionary. Then, we would make a second node iteration that also holds the node respective dialogue scriptable object that we can get from our dictionary by using the node ID, and then iterate through each of the node choices. In that node choices iteration, we would get the dictionary value that has the choice "NodeID" as the key and set the dialogue SO "NextDialogue" value on
the current choice to be that dictionary value. Once we update all that we need, we
will then simply need to save our asset. So start this off by typing in
"foreach (DSNode node in nodes)" and we'll be needing our created dialogues,
so let's create a new variable above by typing in "private static Dictionary<>()" with the key of "string" for the ID, and a value type of "DSDialogueSO", for the corresponding SO". I'll name it "createdDialogues". When that's done, initialize this list in the "Initialize" method. We now need to add our dialogue SO's to this dictionary, so in our "SaveNodeToScriptableObject"
method, after we initialize our dialogue, type in "createdDialogues.Add(node.ID, dialogue)". Back to our
"UpdateDialoguesChoicesConnections" method, we'll create a new variable of type
"DSDialogueSO", to which I'll name "dialogue", and get the respective node dialogue SO by assigning "createdDialogues[node.ID]". We'll now iterate through the nodes choices using a "for" loop. This is because our node and our
dialogue choices are ordered the same, so we can use the index from the "for" loop to
access the current choice of both variables. So type in "for (int choiceIndex = 0;
choiceIndex < node.Choices.Count; ++choiceIndex)" You are free to place the "++" after the "choiceIndex" if you
prefer, as it doesn't really matter here. Inside, create a new variable of type "DSChoiceSaveData", to which I'll name "nodeChoice" and assign the "node.Choices[choiceIndex]" value into it. From that, we have our node choice, so the next thing we'll do is actually check
if its "NodeID" variable is empty. As it if is empty, then we don't really want to
connect this choice with anything. So "if (string.IsNullOrEmpty(nodeChoice.NodeID))" and if that's true, we can "continue;" to the next choice. In case there's an ID, we'll update
the dialogue SO choice by typing in "dialogue.Choices[choiceIndex].NextDialogue = createdDialogues[nodeChoice.NodeID]" When that's done, we need to call in "SaveAsset(dialogue)" at the end of the loop, as the asset was just updated. Our connections are now updated, so all that's left to do is to
remove the unused files and folders. We'll start by removing our unused group assets,
so in our "SaveGroups" method call in a new method at the bottom to which I'll name "UpdateOldGroups"
and pass in "graphData" as an argument. I'll place this method under all the other group methods. The way we'll be removing the unused
files and folders will be quite simple: We have a list of the old group names, and
we'll have a list of the current group names. Whatever old group name isn't in the
current group names list should be deleted. So in our method accept a new
parameter of type "List<string>" to which I'll name "currentGroupNames". We are now accepting the current group names
list but we don't really have that list anywhere. We'll actually get that list
in our "SaveGroups" method, so in there, create a new "List<string>",
to which I'll name "groupNames" and initialize it. Then, at the end of our foreach loop, type
in "groupNames.Add(group.title)". When that's done, we simply pass this
list to our "UpdateOldGroups" method call. Back in our "UpdateOldGroups" method we can start removing the old groups if there is any to be removed. So start things of by typing in
"if (graphData.oldGroupNames != null)", which means the list is initialized, "&& graphData.OldGroupNames.Count != 0", which means there is at least one name in the list. With these two checks passing, we can remove our assets. To get the names that are different between the two lists, we'll be using the "Linq" namespace "Except" method. This method receives two lists and returns every element of the first list except
the ones that are also in the second list. So let's create a new "List<of strings>"
to which I'll name "groupsToRemove" and assign
"graphData.OldGroupNames.Except(currentGroupNames)". Make sure you import the "Linq" namespace. We'll also need to type in ".ToList()" at the end because the "Except" method returns an "IEnumerable". All that's left now is to remove the group
assets, so iterate through them by typing in "foreach (string groupToRemove in groupsToRemove)" and we'll actually be removing the whole folder. This is because this group is no longer
in the graph, and even if the nodes of this group still are in the graph,
because they will be created again in another folder, we don't really need to care
about what's inside of this group folder. To do that, we'll be using a new method
we'll create named "RemoveFolder". We'll be passing in the path of the
folder as an argument so type in "$"{containerFolderPath}/Groups/{groupToRemove}"". I'll place this method in the "Utility Methods"
region right under the "CreateFolder" method. Update the parameter name to be "fullPath" instead. You could separate this into "path"
and "folderName" if you'd prefer. We'll be using the FileUtil class "DeleteFileOrDirectory"
method to remove our folder, so type in "FileUtil.DeleteFileOrDirectory(fullPath)". However, this method actually requires
you to add a "/" at the end of the path. To be consistent with the other methods,
I'll just make this an interpolated string and make the "fullPath" be between curly
brackets ({}) and add the "/" at the end. We'll also need to make sure we remove the ".meta" file. This is because if we don't, Unity will
create this folder for us automatically when we go to the Editor. So above our first removal, simply copy and paste the same line
and swap the "/" with ".meta" instead. Back in our "UpdateOldGroups" method,
we are now removing our group folder. All that's left to do is to update the
"OldGroupNames" variable to have the same names as the current group names,
so after our if check statement type in "graphData.OldGroupNames = new List<string>(currentGroupNames)" We are not assigning the "currentGroupNames"
right away because "List" is a "reference type". We can pass it in to the constructor
because "string" is a primitive type, so it's a "value type". Our group assets are now updated so we
can start doing the same for our nodes. In our "SaveNodes" method, call in a new
method we'll be creating at the bottom named "UpdateOldUngroupedNodes". We'll be needing a list of the
names to send in as arguments so create a new list above of type "string" and I'll name it "ungroupedNodeNames". Don't forget to initialize the list. Then, at the end of the nodes loop,
type in "ungroupedNodeNames.Add(node.DialogueName)" Pass this list and the graph data to the method. I'll place the method under all the other Node Methods. Update the first parameter name
to be "currentUngroupedNodeNames". We'll basically do the same as in our groups
but remove an asset instead of a folder. So type in "if (graphData.OldUngroupedNodeNames != null &&
graphData.OldUngroupedNodeNames.Count != 0)", we can then create a new "List<string>",
to which I'll name "nodesToRemove" and assign "graphData.OldUngroupedNodeNames.Except
(currentUngroupedNodeNames).ToList()". Then, iterate through the nodes by typing in
"foreach (string nodeToRemove in nodesToRemove)". We'll be removing a node asset here so we
first need our own "RemoveAsset" method, so type in "RemoveAsset" and pass in the path as the first parameter,
so "$"{containerFolderPath}/Global/Dialogues"", and the asset name as the second
parameter, so "nodeToRemove". I'll make this method be under our "CreateAsset"
method in the "Utility Methods" region. Update the parameter names to be "path" and "assetName". We'll be using the AssetDatabase
"DeleteAsset" method to delete the asset, so type in "AssetDatabase.DeleteAsset($"{path}/{assetName}.asset")". It is now being removed so back
to our "UpdateOldUngroupedNodes" method the only thing left to do
is to update the graph data variable, so
"graphData.OldUngroupedNodeNames = new List<string>(currentUngroupedNodeNames)" With that done, we need to do
the same for our grouped nodes. So above our "UpdateOldUngroupedNodes"
method call, type in "UpdateOldGroupedNodes". We'll be needing two parameters here as well. So create a new "SerializableDictionary" above,
to which the key type will be "string" and the value type will be "List<string>". The key will be the name of the group the nodes belong to, while the value will be the list of nodes that belong to the group. I'll name it "groupedNodeNames" and initialize it. In our foreach loop, before we add the
node to the dictionary, we need to check if the node belongs to a group,
so "if (node.Group != null)", we can then add it by typing in "groupedNodeNames.AddItem(node.Group.title, node.DialogueName)". Remember to use the "AddItem"
method we've created previously. We then "continue;" to the next element, as
to not add the node as an ungrouped node. Pass in "groupedNodeNames" and "graphData" to the method. I'll place this method above our
"UpdateOldUngroupedNodes" method. Rename the first parameter to
"currentGroupedNodeNames" instead. Then, type in "if (graphData.OldGroupedNodeNames !=
null && graphData.OldGroupedNodeNames.Count != 0)". This one will be a bit different because
we are talking about a Dictionary. While previously we had the list of node names in one variable, here we have a list of nodes per element in the Dictionary. This is because there are multiple groups and
each one of those groups can have grouped nodes. So iterate through the dictionary by typing in "foreach (KeyValuePair<string, List<string>> oldGroupedNode
in graphData.OldGroupedNodeNames)". Inside, we'll be doing what we did in the other methods: We'll get the current dictionary element list
value, which holds this group node names, except the current group node names. To do that, first create a "List<string>" and I'll name it "nodesToRemove", and then initialize it. We will however only remove the nodes if the old
dictionary key exists in the current dictionary. This is because when we deleted the group
assets, we deleted the whole folder, so our nodes were deleted with it. This means that if we already deleted a group, which means its name isn't part of the current dictionary, its nodes were already deleted. So type in
"if (currentGroupedNodeNames.ContainsKey
(oldGroupedNode.Key))", and this simply means that the current
old group we're at is still in the graph, so its folder wasn't removed, then we can set our "nodesToRemove"
to be "= to oldGroupedNode.Value", which gets the old grouped nodes list, ".Except()" and we'll be getting the
"currentGroupedNodeNames[OldGroupedNode.Key]", as that gets the current grouped nodes list
for the element with the same group key, and then type in ".ToList()". Under our if statement, we can now iterate
through the nodes to remove list so "foreach (string nodeToRemove in nodesToRemove)"
and inside remove the asset by typing in "RemoveAsset($"{containerFolderPath}/Groups/{oldGroupedNode.Key}/Dialogues", nodeToRemove)". When that's done, set the
"graphData.OldGroupedNodeNames to be a
"new SerializableDictionary<>(currentGroupedNodeNames)". With this, our Save method is now done. The only thing left to do is to call it somewhere. We'll be doing that in our toolbar "Save"
button, so go to the "DSEditorWindow" script and in our "AddToolbar" method in the "saveButton", add an empty callback and call in a method
we'll be creating named "Save". I'll add this method to a new region I'll name "Toolbar Actions", just under the "Addition Methods" region. We'll start by initializing the Utility
so type in "DSIOUtility.Initialize()" and we'll need to pass in both the
graph view and the graph file name. We don't really have a reference for our
Graph View here so let's create a new variable above by typing in
"private DSGraphView" and I'll name it "graphView". Then, in the "AddGraphView" method,
remove the "graphView" variable type. We can now pass it in to our DSIOUtility "Initialize" method. For our graph file name, we simply
pass in "fileNameTextField.value". Once our Utility is initialized, we call in our
Save method by typing in "DSIOUtility.Save()". We are now saving our Graph and it should work. Before we do that though we have a problem. Right now, we can save the graph if its file name is empty. Of course that shouldn't be possible, so whenever
we try to save the graph with an empty name, we'll throw in an error dialog
saying that we cannot save. To do that, type in at the top of our method "if (string.IsNullOrEmpty(fileNameTextField.value))" and inside we'll display the message by typing
in "EditorUtility.DisplayDialog()", which is a method Unity provides to display a dialog window. For the title, pass in "Invalid file name.". For the message, I'll pass in
"Please ensure the file name you've typed in is valid.". For the third text, I'll pass in "Roger!". The third one is simply the text
that the "OK" button will show. Return at the end of this if statement
so that our graph doesn't get saved. Because of this problem we also found another: Our elements can also have empty names. We'll fix that by updating our
"RepeatedNamesAmount" property so that the Save button gets enabled or disabled depending
on the amount of empty names in the graph. So let's start by going to our "DSNode" script
and in the "dialogueNameTextField" callback, right after updating the value, type in
"if (string.IsNullOrEmpty(target.value))" This means that the current value we've typed in is empty, so we'll need to increment one to the errors. Of course, we only want to do that
if the previous value was not empty. This is because we can add as many spaces as we want and it will count as the value being changed, as we are only removing the whitespaces inside of the callback. So type in "if (!string.IsNullOrEmpty(DialogueName))", as right now the "DialogueName" has the old value. Then, type in "++graphView.RepeatedNamesAmount". For our opposite case, type in "else",
which means the current value isn't empty, and we'll be decrementing one to the
errors if the previous value was empty, so "if (string.IsNullOrEmpty(DialogueName))", and
inside type in "--graphView.RepeatedNamesAmount". You can use the "callback.previousValue" variable if you prefer, but remember to remove whitespaces and special characters. All that's left now is our Group element, so copy the current code and go to the
"DSGraphView" script to our "OnGroupRenamed" callback. Paste the code right after assigning the group title. In here, simply swap the
"target.value" with "dsGroup.title", and the "DialogueName" with "dsGroup.OldTitle" instead. Of course, remove the "graphView." variable
from the "RepeatedNamesAmount" property. I'll also renamed this property
to be "NameErrorsAmount" instead. Of course, rename the private variable as well. If we now save it all and go to Unity,
we should be able to save our Graph. If we were to remove the graph file name
and Save, our Dialog Window should show up. Having no name in the elements
should also disable the Save button. Do note that when removing the whole text
from the Group it will be harder to rename it, and it won't show what you are typing in
when renaming it, but it is indeed renaming. If we were to add elements to the Graph, pressing in our Save button should
create the necessary folders and files. Deleting elements should also
remove their assets accordingly.