Quest description language or how to make a quest system in Unity

Content

  1. Why was the article written?

  2. Formulation of the problem

  3. Solution to the problem

  4. Desired syntax of the future language

  5. Parsing TextParser Code

  6. Parsing the Quest Code

  7. Parsing the DeliveryQuest class

  8. Parsing the ChatQuest Code

  9. Additional Spawn command

  10. System Disadvantages

  11. System Benefits

  12. Conclusion


Why was the article written?

Hey habr! This is my first post, so it’s good manners to introduce yourself. I am an independent mobile video game developer. I have been working for two years Unity and I’m on C#. He released one indie toy, which, although it did not shoot, and did not bring money, received good reviews.

But then the day came when I wanted to try my hand at Habré and talk about an interesting thing that I myself was able to come up with and implement.

Today we will talk about quest system in games. Why was this topic chosen? Because I could not find enough detailed and comprehensive information about it on the net, as a result of which I had to invent it myself. So let’s get started.

Formulation of the problem

Let’s introduce a little terminology so that we speak the same language.

Quest – a certain task, an action that the player must perform.

postal quest – a quest like “give (find) – bring”, the most common and boring of all types of quests.

“Sociable Quest” – a quest like “talk to”, is a simple indication of the character with whom the player needs to start a dialogue.

There are many types of quests. Because of this, we are faced with the problem that during the development of the game certain types of quests can appear in the game (especially when the plot and narrative are not approved). Therefore, during development, we need a fairly simple and extensible quest system.

Problem: if the game needs to have quests, then the developers need a simple and extensible quest system

Solution to the problem

To solve the problem, I propose to create our own simple language – Quest Description Language (hereinafter LDL).

This language will consist of instructions for creating quests, as well as additional instructions for even more convenient work.

In this article, we will create instructions for creating a mail and social quest, as well as an additional instruction for spawning certain objects.

The picture below shows the future class hierarchy that we will create.

Future class hierarchy of NOK
Future class hierarchy of NOK

The horizontal lines represent composition relationships. Vertical – inheritance (in the case of an interface – implementation)

Desired syntax of the future language

The following are the instructions for the language we will be creating.

delivery from 0 to 1 dialogs 1 -1 name QuestName description QuestDesc

Here we say that we want to create a mail quest (delivery instruction), that we receive a quest from an NPC with id 0 and turn in a quest for an NPC with id 1 (from 0 to 1), after which we indicate the id of the corresponding dialogs (dialogs 1 -1) and at the end, the name and description of the quest (name and description).

chat id 0 autoStart false dialog 2 name QuestName description QuestDesc

Here we say that we are creating a sociable quest (chat), that we need to talk to the NPC with id 0 (id 0), we indicate the id of the dialogue (dialog 2) and the name with a description. The autoStart parameter determines whether the quest will be received immediately after the previous one is completed or will be received after a dialogue with someone.

spawn CutSceneTrigger pos 12.57 1 16.22 scene 0

Here we say that we want to create a certain object on the stage (spawn), we indicate what kind of object (CutSceneTrigger), the position of the object and parameters specific to this object. In this case, the id of the cutscene.

Parsing TextParser Code

So let’s get down to implementation. The first thing we will do is create a parser for our language.

public class TextParser
{
    string[] lines;
    public void Parse(string content)
    {
        lines = content.Split(new char[] { '\n' }, System.StringSplitOptions.RemoveEmptyEntries);
    }


    public object CreateQuest()
    {
        object toReturn = null;

        foreach (var line in lines)
        {
            List<string> words = line.Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries).ToList();
            string type = words[0];

            if (IsQuestCommand(words[0]))
            {
                type = "QuestLanguage." + type.ToTitleCase() + "Quest";
                Type t = Type.GetType(type);

                toReturn = Activator.CreateInstance(t, new object[] { line.Remove(0, words[0].Length) });
            }
            else
            {
                type = "QuestLanguage." + type.ToTitleCase();
                Type t = Type.GetType(type);
                Activator.CreateInstance(t, new object[] { line.Remove(0, words[0].Length) });
            }
        }

        return toReturn;
    }

    private bool IsQuestCommand(string command)
    {
        return Type.GetType("QuestLanguage." + command.ToTitleCase() + "Quest") != null;
    }
}

In method Parse we split the original string into an array of strings by the delimiter – \n

In method CreateQuest we run through each line in the lines array, take the first word and check if it is an instruction for creating a quest (the check is carried out in the method IsQuestCommand).

If it is such an instruction, then we create an instance of this quest and pass the rest of the line to its constructor (line 24). At the end of the method, we will return the resulting instance.

If the word is not an instruction for creating quests, this is an additional command. We simply create a new object of this command, whose constructor will do the rest.

Parsing the Quest Code

Move on. Next in line is the base class Qust

public class Quest
{
    public static event System.Action<Quest> QuestPassedEvent;
    public static event System.Action<Quest> QuestGotEvent;

    public string QuestName { get; protected set; }
    public string QuestDescription { get; protected set; }

    public virtual void Pass() => QuestPassedEvent?.Invoke(this);
    public virtual void Got() => QuestGotEvent?.Invoke(this);
    public virtual void Start() { }
    public virtual void Destroy() { }

    public Quest(string parametrs)
    {
        List<string> parList = parametrs.GetWords();

        var nameIndex = parList.FindIndex(s => s == "name");
        var descIndex = parList.FindIndex(s => s == "description");

        QuestName = "";
        for (int i = nameIndex + 1; i < descIndex; i++)
            QuestName += parList[i] + " ";

        QuestDescription = "";
        for (int i = descIndex + 1; i < parList.Count; i++)
            QuestDescription += parList[i] + " ";
    }
}

This class consists of two static events that report the receipt and delivery of the quest, methods Pass and Got (what they do, I think, is clear) and methods start and Destroy. Start and Destroy are needed so that the quest can initialize some of its variables (Start) or clean up after itself (Destroy).

The constructor takes a string and looks up the name and description of the quest, after which it initializes the appropriate fields.

Parsing the DeliveryQuest class

Now let’s see how to create a mail quest

public class DeliveryQuest : Quest
{
    private int fromID;
    private int toID;

    private StartDialogComponent sender;
    private StartDialogComponent target;

    public DeliveryQuest(string parametrs) : base(parametrs)
    {
        ParsingUtility utility = new ParsingUtility(parametrs);

        fromID = utility.GetValue<int>("from");
        toID = utility.GetValue<int>("to");

        var dialogIDs = utility.GetValues<string>("dialogs", 2);


        sender = NPCManagement.NPCManager.GetNPC(fromID).gameObject.AddComponent<StartDialogComponent>();
        sender.SetDialogID(dialogIDs[0]);

        target = NPCManagement.NPCManager.GetNPC(toID).gameObject.AddComponent<StartDialogComponent>();
        target.SetDialogID(dialogIDs[1]);

        DialogSystem.DialogText.DialogActionEvent += GotQuest;
    }

    private void GotQuest(string id, string action)
    {
        if (action == "GotQuest")
            Got();
    }

    private void PassQuest(string id, string action)
    {
        if (action != "PassQuest")
            return;
        Pass();
    }

    public override void Destroy()
    {
        DialogSystem.DialogText.DialogActionEvent -= GotQuest;
    }

    public override void Got()
    {
        base.Got();
        GameObject.Destroy(sender);
        DialogSystem.DialogText.DialogActionEvent -= GotQuest;
        DialogSystem.DialogText.DialogActionEvent += PassQuest;
    }

    public override void Pass()
    {
        base.Pass();
        GameObject.Destroy(target);
    }
}

This class in its constructor takes a string, finds the id of the NPC, adds StartDialogComponent components to them (it can simply start a dialogue with a specific id) and determines the conditions for receiving and passing the quest.

This is where the advantages of this system begin to appear. You can absolutely independently determine when and how the quest will be received and handed over, as well as other additional actions (such as adding and removing components). In my project, such a condition is an event from the dialog system.

Parsing the ChatQuest Code

Now it’s the turn of the social quest

public class ChatQuest : Quest
{
    private int npcID;
    private StartDialogComponent dialogComponent;
    private string dialogID;

    public ChatQuest(string parametr) : base(parametr)
    {

        ParsingUtility utility = new ParsingUtility(parametr);

        npcID = utility.GetValue<int>("id");
        bool autoStart = utility.GetValue<bool>("autoStart");
        dialogID = utility.GetValue<string>("dialog");

        if (autoStart)
            Got();
        else
            DialogSystem.DialogText.DialogActionEvent += GotQuest;


    }

    private void PassQuest(string id, string action)
    {
        if (action == "PassQuest")
            Pass();
    }

    private void GotQuest(string id, string action)
    {
        if (action != "GotQuest")
            return;

        Got();
    }

    public override void Destroy()
    {
        DialogSystem.DialogText.DialogActionEvent -= PassQuest;
    }

    public override void Got()
    {
        base.Got();
        dialogComponent = NPCManagement.NPCManager.GetNPC(npcID).gameObject.AddComponent<StartDialogComponent>();
        dialogComponent.SetDialogID(dialogID);

        DialogSystem.DialogText.DialogActionEvent -= GotQuest;

        DialogSystem.DialogText.DialogActionEvent += PassQuest;
    }

    public override void Pass()
    {
        GameObject.Destroy(dialogComponent);
        base.Pass();
    }
}

This class is similar to DeliveryQuest with the difference that only one dialog component is used here and there is an autostart parameter, which I mentioned above. Everything else is the same. We determine when and how the quest is obtained and surrendered and perform the appropriate actions.

Additional Spawn command

As mentioned earlier, the spawn command is needed to spawn certain objects on the stage. For example, in my project, with this command, I will spawn a cutscene trigger. Thus, we get a lot of game variability, for which game designers will only thank us.

public class Spawn
{
    public Spawn(string parametrs)
    {
        List<string> parList = parametrs.GetWords();

        string typeName = parList[0];
        Type type = Type.GetType("QuestLanguage." + typeName + "Spawner");

        var posIndex = parList.FindIndex(s => s == "pos");
        Debug.Log("X: " + parList[posIndex + 1]);
        float x = float.Parse(parList[posIndex + 1]);
        float y = float.Parse(parList[posIndex + 2]);
        float z = float.Parse(parList[posIndex + 3]);
        Vector3 pos = new Vector3(x, y, z);

        string str = "";
        for (int i = posIndex + 4; i < parList.Count; i++)
            str += parList[i] + " ";

        ISpawner spawner = Activator.CreateInstance(type) as ISpawner;
        spawner.Spawn(str, pos);
    }
}

The command constructor takes a string, finds the name of the object to be created, and then creates a specialized spawner for this object, to which it then passes the rest of the string with additional parameters and the spawn position.

Interface ISpawner has the following form.

public interface ISpawner
{
    void Spawn(string parametrs, Vector3 pos);
}

CutSceneTriggerSpawner implements this interface. It parses a string with additional parameters (cut-scene id), after which it creates a trigger at a given position. When the player touches the trigger, the necessary cut-scene will begin.

System Disadvantages

System Benefits

Conclusion

Here is my first article. An attentive reader could pay attention to methods and classes not considered, but used. Parsing Utility, GetWords and ToTitleCase. They are needed for convenient work with strings and their parsing is beyond the scope of this article.

If the reader wants to get to know the system more, then here is a link to the project in which it is currently used.

GitHub

I’m waiting for suggestions for improving the system 🙂

Thank you for your attention!

Similar Posts

Leave a Reply