Квестовая система диалогов

Продолжаем тему организации диалогов в игре. По сути это эволюция предыдущего нашего проекта, который заточен для создания простых диалогов. Однако на этот раз, внесены значительные изменения, в результате появилась возможность создавать полноценные квесты. Система диалога позволяет следующее: взять квест, отказаться от него или сдать. Всё это регулируется с помощью атрибутов диалога и напрямую зависит от состояния, запрашиваемого квеста. С помощью атрибутов мы можем фильтровать строки диалога. Например, если квест не взят, то показывать один текст, если же квест активен, то тогда будет показан другой текст.


Итак, окно диалога мы делаем на основе Scroll View, а в качестве текстовых полей, будем использовать UI кнопки.

Для кнопки нам понадобится скрипт:

using UnityEngine;
using UnityEngine.UI;
using System.Collections;

public class ButtonComponent : MonoBehaviour {

	public Button button;
	public Text text;
	public RectTransform rect;

}

Тут мы кеширум основные элементы кнопки, чтобы упростить доступ к ним.
button - сама кнопка, text - ее текст, rect - трансформ кнопки.

Затем нужно определиться, сколько вариантов ответа может быть одновременно доступно игроку в диалоге. Если в течении всей игры максимальное число, например, будет равно пяти, то нам нужно создать шесть полей. То есть +1 к общему числу, так как одно текстовое поле, всегда будет использоваться для вывода текста NPC.


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

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

Далее, на Canvas окна диалога, вешаем управляющий скрипт:

using UnityEngine;
using UnityEngine.EventSystems;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using System.Xml;
using System.IO;

public class DialogueManager : MonoBehaviour {

	public ScrollRect scrollRect;
	public ButtonComponent[] buttonText; // первый элемент списка, всегда будет использоваться для вывода текста NPC, остальные элементы для ответов, соответственно, общее их количество должно быть достаточным
	public string folder = "Russian"; // подпапка в Resources, для чтения
	public int offset = 20;

	private string fileName, lastName;
	private List<Dialogue> node;
	private Dialogue dialogue;
	private Answer answer;
	private float curY, height;
	private static DialogueManager _internal;
	private int id;
	private static bool _active;

	public void DialogueStart(string name)
	{
		if(name == string.Empty) return;
		fileName = name;
		Load();
	}

	public static DialogueManager Internal
	{
		get{ return _internal; }
	}

	public static bool isActive
	{
		get{ return _active; }
	}

	void Awake()
	{
		_internal = this;
		CloseWindow();
	}

	void Load()
	{
		if(lastName == fileName) // проверка, чтобы не загружать уже загруженный файл
		{
			BuildDialogue(0);
			return;
		}

		node = new List<Dialogue>();

		try // чтение элементов XML и загрузка значений атрибутов в массивы
		{
			TextAsset binary = Resources.Load<TextAsset>(folder + "/" + fileName);
			XmlTextReader reader = new XmlTextReader(new StringReader(binary.text));

			int index = 0;
			while(reader.Read())
			{
				if(reader.IsStartElement("node"))
				{
					dialogue = new Dialogue();
					dialogue.answer = new List<Answer>();
					dialogue.npcText = reader.GetAttribute("npcText");
					dialogue.id = GetINT(reader.GetAttribute("id"));
					node.Add(dialogue);

					XmlReader inner = reader.ReadSubtree();
					while(inner.ReadToFollowing("answer"))
					{
						answer = new Answer();
						answer.text = reader.GetAttribute("text");
						answer.toNode = GetINT(reader.GetAttribute("toNode"));
						answer.exit = GetBOOL(reader.GetAttribute("exit"));
						answer.questStatus = GetINT(reader.GetAttribute("questStatus"));
						answer.questValue = GetINT(reader.GetAttribute("questValue"));
						answer.questValueGreater = GetINT(reader.GetAttribute("questValueGreater"));
						answer.questName = reader.GetAttribute("questName");
						node[index].answer.Add(answer);
					}
					inner.Close();

					index++;
				}
			}

			lastName = fileName;
			reader.Close();
		}
		catch(System.Exception error)
		{
			Debug.Log(this + " ошибка чтения файла диалога: " + fileName + ".xml | Error: " + error.Message);
			CloseWindow();
			lastName = string.Empty;
		}

		BuildDialogue(0);
	}

	void AddToList(bool exit, int toNode, string text, int questStatus, string questName, bool isActive)
	{
		buttonText[id].text.text = text;
		buttonText[id].rect.sizeDelta = new Vector2(buttonText[id].rect.sizeDelta.x, buttonText[id].text.preferredHeight + offset);
		buttonText[id].button.interactable = isActive;
		height = buttonText[id].rect.sizeDelta.y;
		buttonText[id].rect.anchoredPosition = new Vector2(0, -height/2 - curY);

		if(exit)
		{
			SetExitDialogue(buttonText[id].button);
			if(questStatus != 0) SetQuestStatus(buttonText[id].button, questStatus, questName);
		}
		else
		{
			SetNextNode(buttonText[id].button, toNode);
			if(questStatus != 0) SetQuestStatus(buttonText[id].button, questStatus, questName);
		}

		id++;

		curY += height + offset;
		RectContent();
	}

	void RectContent()
	{
		scrollRect.content.sizeDelta = new Vector2(scrollRect.content.sizeDelta.x, curY);
		scrollRect.content.anchoredPosition = Vector2.zero;
	}

	void ClearDialogue()
	{
		id = 0;
		curY = offset;
		foreach(ButtonComponent b in buttonText)
		{
			b.text.text = string.Empty;
			b.rect.sizeDelta = new Vector2(b.rect.sizeDelta.x, 0);
			b.rect.anchoredPosition = new Vector2(b.rect.anchoredPosition.x, 0);
			b.button.onClick.RemoveAllListeners();
		}
		RectContent();
	}

	void SetQuestStatus(Button button, int i, string name) // событие, для управлением статуса, текущего квеста
	{
		string t = name + "|" + i; // склейка имени квеста и значения, которое ему назначено
		button.onClick.AddListener(() => QuestStatus(t));
	}

	void SetNextNode(Button button, int i) // событие, для перенаправления на другой узел диалога
	{
		button.onClick.AddListener(() => BuildDialogue(i));
	}

	void SetExitDialogue(Button button) // событие, для выхода из диалога
	{
		button.onClick.AddListener(() => CloseWindow());
	}

	void QuestStatus(string s) // меняем статус квеста
	{
		string[] t = s.Split(new char[]{'|'});

		if(t[1] == "1")
		{
			QuestManager.SetQuestStatus(t[0], QuestManager.Status.Active);
		}
		else if(t[1] == "2")
		{
			QuestManager.SetQuestStatus(t[0], QuestManager.Status.Disable);
		}
		else if(t[1] == "3")
		{
			QuestManager.SetQuestStatus(t[0], QuestManager.Status.Complete);
		}
	}

	void CloseWindow() // закрываем окно диалога
	{
		_active = false;
		scrollRect.gameObject.SetActive(false);
	}

	void ShowWindow() // показываем окно диалога
	{
		scrollRect.gameObject.SetActive(true);
		_active = true;
	}

	int FindNodeByID(int i)
	{
		int j = 0;
		foreach(Dialogue d in node)
		{
			if(d.id == i) return j;
			j++;
		}

		return -1;
	}

	void BuildDialogue(int current)
	{
		ClearDialogue();

		int j = FindNodeByID(current);

		if(j < 0)
		{
			Debug.LogError(this + " в диалоге [" + fileName + ".xml] отсутствует или указан неверно идентификатор узла.");
			return;
		}

		AddToList(false, 0, node[j].npcText, 0, string.Empty, false); // добавление текста NPC

		for(int i = 0; i < node[j].answer.Count; i++)
		{
			int value = QuestManager.GetCurrentValue(node[j].answer[i].questName);

			// фильтр ответов, относительно текущего статуса квеста
			if(value >= node[j].answer[i].questValueGreater && node[j].answer[i].questValueGreater != 0 || 
				node[j].answer[i].questValue == value && node[j].answer[i].questValueGreater == 0 || 
				node[j].answer[i].questName == null)
			{
				AddToList(node[j].answer[i].exit, node[j].answer[i].toNode, node[j].answer[i].text, node[j].answer[i].questStatus, node[j].answer[i].questName, true); // текст игрока
			}
		}

		EventSystem.current.SetSelectedGameObject(scrollRect.gameObject); // выбор окна диалога как активного, чтобы снять выделение с кнопок диалога
		ShowWindow();
	}

	int GetINT(string text)
	{
		int value;
		if(int.TryParse(text, out value))
		{
			return value;
		}
		return 0;
	}

	bool GetBOOL(string text)
	{
		bool value;
		if(bool.TryParse(text, out value))
		{
			return value;
		}
		return false;
	}
}
	
class Dialogue
{
	public int id;
	public string npcText;
	public List<Answer> answer;
}


class Answer
{
	public string text, questName;
	public int toNode, questValue, questValueGreater, questStatus;
	public bool exit;
}

Здесь и происходит магия. Логика его работы в принципе простая, загрузка и расшифровка файла, в котором прописаны теги и текст диалога. Данный менеджер работает исключительно в связке с менеджером скриптов.

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

Менеджер квестов выглядит следующим образом:

using UnityEngine;
using System.Collections;

public class QuestManager : MonoBehaviour {

	public enum Status {Disable, Active, Complete};

	public static int GetCurrentValue(string questName) // запрос статуса по имени квеста
	{
		int j = 0;

		switch(questName)
		{
		case "TestQuest":
			// здесь имеет смысл использовать условие и запрос из сохранения, чтобы проверить завершен квест или нет, если да то, передать значение: -1
			j = TestQuest.questValue;
			break;
		}

		return j;
	}
	
	public static void SetQuestStatus(string questName, Status status) // изменения статуса, указанного квеста
	{
		switch(questName)
		{
		case "TestQuest":
			TestQuest.Internal.QuestStatus(status);
			break;
		}
	}
}

Нам нужно две функции, это запрашивать состояние прогресса у конкретного квеста, и изменение состояния квеста. Например, когда в диалоге мы нажимаем на кнопку "Я выполни задание" - то необходимо сообщить квету соответствующий статус. А здесь в качестве квеста у нас TestQuest.


Теперь уточнение по файлу тегам и атрибутам. Файл диалога должен храниться в папке, которая указана в менеджере диалога, например Russian , то есть мы имеем возможность использовать разные языки, достаточно лишь менять имя папки, при этом все должны находится в Resources.

XML файл диалога:

<dialogue name="Example">
  <node id="0" npcText="Приветствую!">
    <answer text="Как делишки?" toNode="1" />
    <answer text="Мне пора." exit="True" />
  </node>
  <node id="1" npcText="Вполне неплохо…">
    <answer text="Понятно." toNode="0" />
    <answer text="У меня дела, пока." exit="True" />
  </node>
</dialogue>


Тег node для вывода текста NPC, иначе говоря это узел диалога. Данный тег обязательно должен иметь атрибут - id, который необходим для идентификации узла, и атрибут - npcText, содержащий сам текст.

Тег answer используется для вывода меню диалога, ответы игрока. Атрибуты тега:

toNode - перенаправление на указанный идентификатор узла. (исключает атрибут exit)

exit - закрывает окно диалога. (исключает атрибут toNode)

questName - имя квеста с который нужно взаимодействовать, это обязательный атрибут, если указаны ниже следующие.

questValue - строка диалога будет показана, если прогресс квеста строго соответствует указанному значению. (исключает атрибут questValueGreater)

questValueGreater - строка диалога будет показана только если прогресс квеста больше или равен, указанному значению (исключает атрибут questValue)

questStatus - отправка менеджеру квестов, текущее состояние указанного квеста (взять квест = 1, отказаться от квеста = 2, сдать квест = 3, неактивно = 0)

Если мы хотим чтобы строка меню отображалась, пока квест еще не активен, то:

<dialogue name="Example">
  <node id="0" npcText="Приветствую!">
    <answer text="Могу я чем-нибудь помочь?" toNode="1" questValue="0" questName="TestQuest" />
  </node>
</dialogue>

Если квест активен то тогда questValue не будет равно нулю, соответственно эта строка не появится в диалоге.

При этом состояние questValue мы запрашиваем у менеджера, который в свою очередь делает запрос у конкретного квеста. Это значит, что нам нужно прописывать состояние переменной в каждом квесте отдельно, так как каждый из них уникален.

Важно не забывать что при использовании questValue / questValueGreater / questStatus - необходимо всегда добавлять тег questName с именем квеста. Такая конструкция позволяет создавать ситуации, что один NPC может быть связан с разными квестами. Что в свою очередь крайне удобно.

Предлагаем скачать рабочий пример проекта:

Внимание! Посетители, находящиеся в группе Гости, не могут скачивать файлы.


В этом проекте реализован квест, который можно выполнить. И наглядно посмотреть как организованно взаимодействие скриптов. Кроме того, в проекте присутствует генератор диалога, с помощью которого был создан файл диалога. В общем, изучаем и пробуем.
Тестировалось на: Unity 5.3.5

Комментариев 8

Офлайн
Light 4 февраля 2019
Terrosertea, любой квест, вещь сугубо индивидуальная. Сам по себе, квест, не является частью диалоговой системы, что дает широкие возможности для его написания, он может быть каким угодно сложными или простым. Система диалогов, запрашивает состояние квеста или меняет его (отправляет ему некое значение). Я рекомендую посмотреть это https://null-code.ru/project/216-redaktor-kvestovyh-dialogov.html, здесь кроме системы диалогов, еще есть и удобный редактор, кроме такого тут возможности шире.
Офлайн
Terrosertea 4 февраля 2019
Light,не поможешь мне? Я немного не понимаю, допустим какое то событие происходит с шансом 4.52% и во время этого события некий персонаж даёт задание с шансом 10.37% (к примеру собрать шапочки для вечеринки). Извини, но я уже всё перепробовал...
Офлайн
Dakurlz 7 марта 2018
Возможно нужно ввести новую переменную, которая будет вести счёт квестов у данного НПС, то есть изначально этот параметр будет равен нулю, после прохождения квеста он будет равен + 1 и так далее. Затем просто в диалоге ввести атрибут, который будет равен этой переменной. Но вот как всё это провернуть на практике я к сожалению не знаю.
Офлайн
Dakurlz 7 марта 2018
Light,
Благодарю за ответ, но я имел в виду то, что бы сама строка в диалоге появлялась только после прохождения первого квеста. Ну то есть первый квест - хорошо, там мы можем просто проверить чему равен value квеста с его именем. А если квест второй по очереди, то мне как то нужно проверить:
1) То что первый квест завершён
2) То что второй квест ещё не взят
Я пытался написать вот так
"Есть задание для меня?" node="2" value="3" quest="Quest" node="2" value="0" quest="Quest2"
Но естественно ничего не работает) Если можно - дайте пожалуйста простенький пример.
Офлайн
Light 7 января 2018
Dakurlz, запрашивать состояние того или иного квеста GetCurrentValue, перед тем как открывать какой-то другой.
Офлайн
Dakurlz 7 января 2018
А как сделать так, что бы второй квест появлялся только после прохождения первого:?
Офлайн
ДимаМихайлов,
Это скрипт RigidBodyPickUp ,его можно найти на assetstore ,ну или бесплатно ,если поищешь в яндексе
Офлайн
Пожалуйста мне нужна помощь !!!!! сделайте урок по этому скрипту https://github.com/mztek/Unity-Scripts/blob/master/DragRigidbody.js
и видео https://www.youtube.com/watch?v=4uY47P2VZKk
Информация
Посетители, находящиеся в группе Гости, не могут оставлять комментарии к данной публикации.
  • Яндекс.Метрика