Содержание
Почему написана статья?
Привет, 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. Они нужны для удобной работы со строками и их разбор не входит в тематику данной статьи.
Если читатель хочет побольше познакомиться с системой, то вот ссылка на проект, в котором она сейчас используется.
Жду пожеланий по улучшению системы :)
Спасибо за внимание!
Автор:
Leo506