Create custom event nodes - enriquemorenotent/unity-conversa-support GitHub Wiki
Event nodes are the ones that trigger an action, when the flow of the conversation goes through them. They can usually be identified because they contain the white ports used to define the events happening before and after them.
Preparations
Conversa offers by default a "Message node". This is used to declare messages that the actors in the conversation communicate. These nodes is composed of 2 fields: Actor and Message.
But now, let's imagine that our messaging system is more complex than that. We also want to add third field, in where we will define the font size of the message. That way some messages will come across as screams, while others might come across as whispers. We will call this the Size Messade Node
Bootstrap the code
Conversa offers a simple mechanism to bootstrap the creation of new nodes. Inside the "Project" tab, right click inside the folder were you want to store your custom nodes, and select the menu "Create > Conversa > Create custom node > Event node".
A new folder will appear, waiting for a name to be given. In this casee we will call it "SizeMessage". Once done, inside the folder there will be 3 files created:
- Event file
- Node file
- Nodeview file
- Menu modifier file
Set a new event
Event nodes might sometimes (though not necessarily always) fire events that might be captured by the game code. For instance, the "Message node" fires a "MessageEvent" that the developer will probably use to update the game's UI.
In this case, we will have to define a new event, that will enclose all the data our new node requests:
using System;
using Conversa.Runtime.Interfaces;
namespace MyGame
{
public class SizeMessageEvent : IConversationEvent
{
public string Actor { get; }
public string Message { get; }
public int Size { get; }
public Action Advance { get; }
public SizeMessageEvent(string actor, string message, int size, Action advance)
{
Actor = actor;
Message = message;
Size = size;
Advance = advance;
}
}
}
To define a new Conversa event, all you have to do is to make sure that your class inherits from IConversationEvent. Do not forget to import then namespace for it, with using Conversa.Runtime.Interfaces;
This class is almost an identical copy of the MessageEvent class, but we have added a new integer field to store the size of the message.
Set up a new node
After defining the event, it is time to define the new node. In this case we will call it "SizeMessageNode".
using System;
using System.Linq;
using Conversa.Runtime.Events;
using Conversa.Runtime.Interfaces;
namespace Conversa.Runtime.Nodes
{
[Serializable]
[Port("Previous", "previous", typeof(BaseNode), Flow.In, Capacity.Many)]
[Port("Next", "next", typeof(BaseNode), Flow.Out, Capacity.One)]
public class SizeMessageNode : BaseNode, IEventNode
{
public string actor;
public string message;
public int size;
public void Process(Conversation conversation, ConversationEvents conversationEvents)
{
var e = new SizeMessageEvent(actor, message, size, () =>
{
var nextNode = conversation.GetOppositeNodes(GetNodePort("next")).FirstOrDefault();
conversation.Process(nextNode, conversationEvents);
});
conversationEvents.OnConversationEvent.Invoke(e);
}
}
}
Again, this class is very similar to the original MessageNode class, with a few tweaks.
First of all, to define an event node, make sure that your class inherits from BaseNode and IEventNode.
Then, it is important to mark the node as Serializable, to make sure the data gets properly saved to this, after saving.
Defining the ports inside your port can be done with the Port attribute.
[Port("Previous", "previous", typeof(BaseNode), Flow.In, Capacity.Many)]
In this case we are defining a port called "previous", with the label "Previous". Because it is port where the flow of the conversation goes through, we will set its type to typeof(BaseNode). Finally we must define if it is an Input or Output port, and finally how many connections it can hold, with either Capacity.One or Capacity.Many.
An event node always contains a method called Process. This method will get executed when the flow of the conversation reaches this node. What we will you in this method is to define a new instance of the event type we defined in the previous section.
public void Process(Conversation conversation, ConversationEvents conversationEvents)
{
var e = new SizeMessageEvent(actor, message, size, () =>
{
var nextNode = conversation.GetOppositeNodes(GetNodePort("next")).FirstOrDefault();
conversation.Process(nextNode, conversationEvents);
});
conversationEvents.OnConversationEvent.Invoke(e);
}
First we create the SizeMessageEvent with the constructor passing the actor, message and size of that message. The last parameter of the constructor is the function that will need to be invoked, for the user to advance to the next node of the conversation. In this case, it is a pretty linear flow. All you have to do it fetch the node connected to the port "next". But if we had something more complex, like a branch node, we could decide what port to use, to decide towards which node the conversation would have to flow to. (Feel free to look at the file ConditionalNode if you would like to do how this is done).
Set up the node view
Now that we have the node, and the event that it fires, we need to work on the representation of the node inside the editor. For that we will build a new nodeview class.
using Conversa.Runtime;
using Conversa.Runtime.Nodes;
using UnityEngine;
using UnityEngine.UIElements;
namespace MyGame
{
public class SizeMessageNodeView : BaseNodeView<SizeMessageNode>
{
protected override string Title => "Size Message";
// Constructors
public SizeMessageNodeView(Conversation conversation) : base(new SizeMessageNode(), conversation) { }
public SizeMessageNodeView(SizeMessageNode data, Conversation conversation) : base(data, conversation) { }
// Methods
protected override void SetBody() { ... }
}
}
VERY IMPORTANT: This is a file that will be used only in the editor, which means that it must not be included inside the final build of your game. Following Unity's convention, all the files that are meant to work only in the editor must be places inside a folder called "Editor". It does not matter where this folder is located, but the name must be exactly "Editor".
Once the file has been correctly located inside your file structure, make sure that it inherits from BaseNodeView<T>, where T will be the class name of the node this view will represent. In this case, it would be SizeMessageNode
You can override the name of the node in the editor, using the "Title" property. This is not mandatory, but recommended.
You will define 2 constructors. One where only the "Conversation" object will be in the parameters, and one that will also include the node data this view represents.
If you wish to know why this is, the first constructor will be used when you create a new node in your graph, and the second will be used when you are loading a conversation from the disk, that contains already such data, to prepopulate the view. You can use the first constructor for example to define what do you want the default values be for your node, when creating a new instance.
Setting the body of the view
Now we will build the main body of the node view. Conversa uses UIElements to build the interface of these nodes. There are plenty of ways that you can build your messages, for example using UXML templates. This decision will be completely up to you, and your expertise with UIElements. In this case, we are going to build it directly via code, to make it as straightforward as possible.
protected override void SetBody()
{
actorField = new TextField();
actorField.SetValueWithoutNotify(Data.actor);
actorField.RegisterValueChangedCallback(e => Data.actor = e.newValue);
actorField.isDelayed = true;
messageField = new TextField();
messageField.SetValueWithoutNotify(Data.message);
messageField.RegisterValueChangedCallback(e => Data.message = e.newValue);
messageField.isDelayed = true;
sizeField = new IntegerField();
sizeField.SetValueWithoutNotify(Data.size);
sizeField.RegisterValueChangedCallback(e => Data.size = e.newValue);
sizeField.isDelayed = true;
bodyContainer.Add(actorField);
bodyContainer.Add(messageField);
bodyContainer.Add(sizeField);
}
Inside a class derived from BaseNodeView, you will always have access to a field called Data. This will be value of the node the view is representing, and it will be of the type you defined in the generic parameter. In this case, a BaseNodeView<SizeMessageNode> will have a Data field of type SizeMessageNode.
Using this value, we will create 3 different fields, one for the actor, message, and size, and we will add callbacks so that the values get updated whenever someone makes changes in the editor. Finally we will append those fields inside the mainContainer of the node.
With only this, your node is ready to be visualized.
Adding the nodes to the graph.
The last part is how to add these new nodes you created into the view. When you right click on the conversa editor, a popup menu appears with a list of all the available nodes you can use. To add the new SizeMessageNode to this list, you need to create a static method like this:
[NodeMenuModifier(6)]
private static void ModifyMenu(NodeMenuTree tree, Conversation conversation)
{
tree.AddGroup("My custom nodes");
tree.AddEntry<SizeMessageNodeView>("Size message");
}
This method can be anywhere inside the Editor folder, but I recommend adding it to the nodeview class, since it is where it makes most sense to have it.
You will first add the NodeMenuModifier attribute, setting up the priority of this method (this will help you position your entry in different positions within the menu).
After that, it is just as simple as using tree.AddEntry to create a new menu entry. To keep things organized, it is a good idea that you group all your custom nodes inside a group, with tree.AddGroup.
Handling the event
Now you should be capable to create, edit and delete as many "size message nodes" as you wish. Now to consume this information inside your game, you should just go to the method that you use to process the different events that the conversation can fire at you. (I will use for this example the demo ConversaController provided in the Demo folder)
private void HandleConversationEvent(IConversationEvent e)
{
switch (e)
{
case MessageEvent messageEvent:
HandleMessage(messageEvent);
break;
case ChoiceEvent choiceEvent:
HandleChoice(choiceEvent);
break;
case UserEvent userEvent:
HandleUserEvent(userEvent);
break;
case EndEvent _:
HandleEnd();
break;
}
}
and just add a new entry, where you will take care of the actions needed, when your custom events fires.
private void HandleConversationEvent(IConversationEvent e)
{
switch (e)
{
case MessageEvent messageEvent:
HandleMessage(messageEvent);
break;
case SizeMessageEvent sizeMessageEvent:
// Do as you wish... ;)
break;
case ChoiceEvent choiceEvent:
HandleChoice(choiceEvent);
break;
case UserEvent userEvent:
HandleUserEvent(userEvent);
break;
case EndEvent _:
HandleEnd();
break;
}
}