Содержание
Почему написана статья?
Привет, 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. Они нужны для удобной работы со строками и их разбор не входит в тематику данной статьи.
Если читатель хочет побольше познакомиться с системой, то вот ссылка на проект, в котором она сейчас используется.
Жду пожеланий по улучшению системы 🙂
Спасибо за внимание!
ссылка на оригинал статьи https://habr.com/ru/post/676010/
Добавить комментарий