Binding Tokenized Text to .NET Properties Using Attributes

Published on

Introduction

When I was working on my Unity Visual Novel Framework, I had a focus on writing and shaping it to be easy to use, but easy to expand if the user wants to. Since the framework was build on a visual node tool, this’d mean that new nodes created by the user should be picked up by the system, plus any parameters or values that the user wants to show inside that node.

Custom displaying any type of property and fitting them visually inside a node turned out pretty easy… in the beginning that is. When more nodes with more complicated features had to be written, I was dreading the moment that I had to use Unity’s layout system to showcase this node inside the graph.

In the end, I wrote up a bunch of nodes, made the classes and methods public for anyone to expand on the existing library of nodes and left it at that. Few months later I decided to pick the project back up again and give it a new coat of code and decided to shift the focus towards writers, instead of a visually aided system.

Setup

Giving a user easy to use tools to turn their text into a living story is what I wanted to achieve with the rewrite of my framework. To do this, I want to turn the text that they write into .NET classes with it’s properties bound to parameters that are mentioned in the sentence. Now this idea sounded a bit too wild, so instead of a user being able to write:

Adam appeared on screen, asking where Jacob had been.

I’d limit it to commands, following it’s parameters:

char “Adam” emotion: “Happy” pos: (0.1, 0.5) mirrored: true

say “Hey Jacob, where have you been?” name: “Adam”

Which does the same as the quote above, but can be expanded easier, seeing as you have more fine control over what eventually happens on screen.

To minimize any future confusion, I made some ground rules for this system to properly work:

  1. Each command starts with an alias for the class to be initiated
  2. A property starts with it’s name, followed by a : with it’s value following it
  3. Every line is a new command
  4. Every alias (albeit class or property) ends with a whitespace

These rules made it easy to write a text parser, since this minimizes the edge cases that I’d have to work around.

Preparing the classes

Since this was written for my Visual Novel framework, all the classes are related to some form of action in such an environment, but these methods can be applied to any project (just need to change up the names and you’re good to go).

Because I want to bind the text to a class with an alias, I can’t use the class’ name, since writing out a full DialogueScriptLine // ... stops the flow of writing your story completely. So being able to shorten it to something like char // ... would be a big help. I achieved this by creating a ScriptLineAliasAttribute attribute, which takes in a string (alias).

Assets/UVNF/Core/Entities/Scripts/ScriptLines/Attributes/ScriptLineAliasAttribute.cs
[AttributeUsage(AttributeTargets.Class), Serializable]
public class ScriptLineAliasAttribute : Attribute
{
public readonly string Alias;
public ScriptLineAliasAttribute(string alias) => Alias = alias;
// ...
}

Pretty simple, but saves the hassle of writing out the entire name of the class in your story. After this, the attribute should be placed on a generic and abstract class that will hold the properties that will be parsed. A user should be able to inherit from this class and get the full functionality of getting their tokenized text into it’s properties (which will be explained in Creating the Bind).

Assets/UVNF/Core/Entities/Scripts/ScriptLines/Attributes/Parameters/Base/ScriptLineAttribute.cs
[AttributeUsage(AttributeTargets.Field)]
public abstract class ScriptLineParameterAttribute : ScriptLineAttribute
{
public object DefaultValue { get; }
public readonly string Label {get;}
public ScriptLineParameterAttribute(string label, object defaultValue)
{
Label = label;
DefaultValue = defaultValue;
}
public abstract object ParseParameterValue(string parameter);
public virtual string ValueToString(object value) => value.ToString();
}

The ScriptLineParameterAttribute is the bread and butter of this whole system, since it’s the attribute that will sit on top of our class’ properties to tell the class how to parse the string value to the value of the property. The label is the name of the parameter that’s mentioned in the sentence (in the example mentioned in Setup, it’d be pos: (0.1, 0.5)) Take for example the IntParameterAttribute, which inherits from the ScriptLineParameterAttribute and parses the incoming value to an int:

Assets/UVNF/Core/Entities/Scripts/ScriptLines/Attributes/Parameters/IntParameterAttribute.cs
public class IntParameterAttribute : ScriptLineParameterAttribute
{
public IntParameterAttribute(string label, int defaultValue = 0) : base(label, defaultValue) { }
public override object ParseParameterValue(string parameter)
{
if (int.TryParse(parameter, out int parsedValue))
return parsedValue;
return default(int);
}
}

The ParseParameterValue will be called from our class’ once values start rolling on and will return the parsed value from the string. This value will be set to the property that this attribute is set to. If we’ve made several of these attributes for different data types, we can move on to the process of parsing the text so that they can be picked up by our class to assign their respected values to the properties.

In my case, I’ve made attributes for Text, Float, Enum, Boolean and Vector2. These can be found here:

UVNF - Parameters

Next up is parsing the text so they can be injected into our properties.

Parsing Text

Now for the hard part, how do you chop these lines into usable strings? First off, reading all the lines of the file, which is easily done using the File.ReadAllLines() method. With each line already consisting of a single command (rule 1), from this point it’s a matter of dissecting the line into the alias of the class and the properties that can be passed. I’ve made a separate classes that handles the parsing and creating of these property holding classes and called them DefaultScriptParser and ScriptLineFactory. First I’ll explain the process of handling text.

string[] dissectedLine = line.Split(' ');
Type lineType = GetTypeFromTag(dissectedLine[0]);

The first part is easy, since each part of the command is split by a whitespace, the alias of the class can easily be fetched (being the first occurring word in the line). The process of instantiating the right class for the given alias is pretty simple: instantiate a list of all classes that inherit from your generic class holding your properties. Since this process has already been explained countless times (how to use reflection to get a certain type), I won’t be explaining it here.

private Type GetTypeFromTag(string tagLine)
{
string title = tagLine.Substring(1, tagLine.Length - 1);
// _scriptLineTypes is the array containing the reflected instances of your base class
for (int i = 0; i < _scriptLineTypes.Length; i++)
{
string lineAlias = string.Empty;
if (_scriptLineTypes[i].GetType().GetCustomAttribute<ScriptLineAliasAttribute>() is ScriptLineAliasAttribute aliasAttribute)
lineAlias = aliasAttribute.GetAlias();
if (title.Equals(lineAlias))
return _scriptLineTypes[i].GetType();
}
return null;
}

The highlighted line is the part where the generic ScriptLineAliasAttribute attribute is read from the class, after which, the alias is read and checked with the line gotten from the text file. So all that’s left is to just get the parameters and their values from the rest of the string right? Should be easy as pie.

if (lineType != null)
{
dissectedLine = string.Join(" ", dissectedLine, 1, dissectedLine.Length - 1).Split(':');
// Item1 = Label, Item2 = Value
(string, string)[] valueCombo = new (string, string)[dissectedLine.Length - 1];
if (valueCombo.Length > 0)
{
for (int i = 0; i < dissectedLine.Length; i++)
{
int splitIndex = dissectedLine[i].LastIndexOf(' ');
if (i == 0)
valueCombo[i].Item1 = dissectedLine[i].Substring(1, dissectedLine[i].Length - 1);
else if (i == dissectedLine.Length - 1)
valueCombo[i - 1].Item2 = dissectedLine[i];
else
{
valueCombo[i - 1].Item2 = dissectedLine[i].Substring(0, splitIndex);
valueCombo[i].Item1 = dissectedLine[i].Substring(splitIndex + 1, dissectedLine[i].Length - splitIndex - 1);
}
}
}
script.AddLine(factory.CreateScriptLine(lineType, valueCombo));
}

Alright, so it’s a bit more complicated than just splitting some lines. Personally when I was working on this, I thought it’d be an easy task too, but it required a bit more work than I thought. Let’s go through this code piece by piece with an example line from the text file:

char name: “Velorexe” emotion: Happy pos: (0.1, 0.5) mirrored: true

dissectedLine = string.Join(" ", dissectedLine, 0, dissectedLine.Length - 1).Split(':');

The dissected line is joined back together, then split on the :, resulting in an array of:

[ char name, “Velorexe” emotion, Happy pos, (0.1, 0.5) mirrored, ]

WIP, come back later

Creating the Bind

WIP, come back later