Язык описания квестов или как сделать квестовую систему на Unity

в 22:32, , рубрики: C#, unity, игры, квест, Программирование, разработка игр

Содержание

  1. Почему написана статья?

  2. Постановка проблемы

  3. Вариант решения проблемы

  4. Желаемый синтаксис будущего языка

  5. Разбор кода TextParser

  6. Разбор кода Quest

  7. Разбор класса DeliveryQuest

  8. Разбор кода ChatQuest

  9. Дополнительная команда Spawn

  10. Недостатки системы

  11. Преимущества системы

  12. Заключение


Почему написана статья?

Привет, habr! Это моя первая статья, поэтому будет хорошим тоном представиться. Я независимый разработчик мобильных видеоигр. Два года работаю на Unity и прогаю на C#. Выпустил одну инди-игрушку, которая хоть и не выстрелила, и не принесла денег, получила неплохие отзывы.

Но вот пришел тот день, когда я захотел попробовать себя на хабре и рассказать об интересной вещи, которую я сам смог придумать и реализовать.

Сегодня речь пойдет о квестовой системе в играх. Почему была выбрана эта тема? Потому что я не смог найти в сети достаточно подробной и исчерпывающей информации о ней, вследствие чего мне пришлось придумывать ее самостоятельно. Итак, давайте приступим.

Постановка проблемы

Давайте введем небольшую терминологию, чтобы мы разговаривали на одном языке.

Квест – некое задание, действие, которое игрок должен выполнить.

Почтовый квест – квест типа «подай(найди)-принеси», самый распространенный и скучный из всех видов квестов.

«Общительный квест» - квест типа «поговори с», представляет собой простое указание на персонажа, с которым игроку надо начать диалог.

Существует множество видов квестов. Из-за этого мы сталкиваемся с проблемой, что во время разработки игры те или иные виды квестов могут появляться в игре (особенно когда сюжет и нарратив не утверждены). Поэтому во время разработки нам нужна достаточно простая и расширяемая система квестов.

Проблема: если в игре должны быть квесты, то разработчикам нужна простая и расширяемая квестовая система

Вариант решения проблемы

Для решения поставленной проблемы я предлагаю создать собственный несложный язык – Язык Описания Квестов (далее ЯОК).

Данный язык будет состоять из инструкций по созданию квестов, а также из дополнительных инструкций для еще более удобной работы.

В данной статье мы создадим инструкции для создания почтового и общительного квеста, а также дополнительную инструкцию для спавна определенных объектов.

Ниже на картинке приведена будущая иерархия классов, которую мы создадим.

Будущая иерархия классов ЯОК
Будущая иерархия классов ЯОК

Горизонтальными линиями представлены отношения композиции. Вертикальными – наследования (в случае интерфейса – реализации)

Желаемый синтаксис будущего языка

Далее представлены инструкции языка, который мы будем создавать.

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

Здесь мы говорим, что хотим создать почтовый квест (инструкция delivery), что мы получаем квест от NPC с id 0 и сдаем квест NPC с id 1 (from 0 to 1), после чего указываем id соответствующих диалогов (dialogs 1 -1) и в конце название и описание квеста (name и description).

chat id 0 autoStart false dialog 2 name QuestName description QuestDesc

Здесь мы говорим, что создаем общительный квест (chat), что нужно поговорить с NPC с id 0 (id 0), указываем id диалога (dialog 2) и название с описанием. Параметр autoStart отвечает будет ли квест получен сразу после завершения предыдущего или будет получен после диалога с кем-либо.

spawn CutSceneTrigger pos 12,57 1 16,22 scene 0

Здесь мы говорим, что хотим создать на сцене некий объект (spawn), указываем что за объект (CutSceneTrigger), позицию объекта и специфические для этого объекта параметры. В данном случае – id кат-сцены.

Разбор кода TextParser

Итак, давайте приступим уже к реализации. Первое, что мы сделаем – создадим парсер нашего языка.

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;
    }
}

В методе Parse мы разбиваем исходную строку на массив строк по разделителю - n

В методе CreateQuest мы пробегаемся по каждой строке в массиве lines, берем первое слово и проверяем является ли оно инструкцией по созданию квеста (проверка осуществляется в методе IsQuestCommand).

Если оно является такой инструкцией – то создаем экземпляр этого квеста и передаем в его конструктор остаток линии (строка 24). В конце метода мы вернем полученный экземпляр.

Если слово не является инструкцией по созданию квестов – это дополнительная команда. Просто создаем новый объект этой команды, конструктор которой сделает все остальное.

Разбор кода Quest

Идем дальше. На очереди базовый класс 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] + " ";
    }
}

Данный класс состоит из двух статических событий, сообщающих о получении и сдачи квеста, методов Pass и Got (что они делают, думаю, понятно) и методов Start и Destroy. Start и Destroy нужны для того, чтобы квест мог проинициализировать какие-то свои переменные (Start) или прибрать за собой (Destroy).

Конструктор принимает строку и ищет название и описание квеста, после чего инициализирует соответствующие поля.

Разбор класса DeliveryQuest

Теперь посмотрим, как создать почтовый квест

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);
    }
}

Данный класс в своем конструкторе принимает строку, находит id NPC, добавляет им компоненты StartDialogComponent (он просто может начать диалог с определенным id) и определяет условия получения и сдачи квеста.

Здесь как раз и начинают проявляться преимущества данной системы. Вы можете абсолютно самостоятельно определить, когда и как будет происходить получение и сдача квеста, а также другие дополнительные действия (как, например, добавление и удаление компонентов). В моем проекте таким условием является событие от диалоговой системы.

Разбор кода ChatQuest

Теперь очередь общительного квеста

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();
    }
}

Данный класс похож на DeliveryQuest с тем отличием, что здесь используется только один компонент диалога и есть параметра автостарта, о котором я говорил выше. Все остальное такое же. Определяем, когда и как получается и сдается квест и выполняем соответствующие действия.

Дополнительная команда Spawn

Как уже говорилось ранее, команда spawn нужна чтобы спавнить на сцене определенные объекты. Например, в моем проекте при помощи этой команды я спавню триггер кат-сцены. Таким образом мы получаем большую игровую вариативность, за что геймдизайнеры нам только спасибо скажут.

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);
    }
}

Конструктор команды принимает строку, находит название объекта, который надо создать, после чего создает специализированный спавнер для этого объекта, которому потом передает остаток строки с доп параметрами и позицию спавна.

Интерфейс ISpawner имеет следующий вид.

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

CutSceneTriggerSpawner реализует данный интерфейс. Он парсит строку с доп параметрами (id кат-сцены), после чего создает триггер в заданной позиции. Когда игрок затронет триггер – начнется необходимая кат-сцена.

Недостатки системы

  • Для ее поддержки и расширения необходим программист. Иными словами, геймдизайнер не сможет по своей прихоти создать новый вид квестов самостоятельно, ему обязательно нужна помощь программиста (если он сам не программист)

  • Довольно большое кол-во файлов и классов, что может потом запутать

  • Сложность синтаксиса. Поначалу будет сложно запомнить, как правильно писать каждую инструкцию. А если они будут потом еще добавляться (что и подразумевает данная система) выучить их все будет еще сложнее

Преимущества системы

  • Расширяемость. Для добавления новых инструкций не нужно изменять существующий код. Просто пишешь новый класс квеста

  • Изолированность. Квесты полностью изолированы от остальной части игры (в том плане, что никто не знает об квестах)

  • Большая игровая вариативность. Благодаря ЯОК мы можем создать квест, добавить после этого кат-сцену или переход на какую-либо другую сцену, что даст геймдизайнеру простор для творчества

Заключение

Вот такой вышла моя первая статья. Внимательный читатель мог обратить внимание на не рассмотренные, но используемые методы и классы ParsingUtility, GetWords и ToTitleCase. Они нужны для удобной работы со строками и их разбор не входит в тематику данной статьи.

Если читатель хочет побольше познакомиться с системой, то вот ссылка на проект, в котором она сейчас используется.

GitHub

Жду пожеланий по улучшению системы :)

Спасибо за внимание!

Автор:
Leo506

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js