Архитектура диалоговой системы в Unity
Когда я начинал разработку своей игры, то не смог найти каких-то внятных гайдов с описанием архитектуры диалоговой системы. Зачастую авторы упоминали верстку да логику UI, но не отвечали на вопросы «как менять сюжетные стейджи», «как работать с разными типами диалогов», «как менять статус персонажам на сцене» и т.п. Мне не хватало найденной информации и я потратил какое-то время на написание диалоговой системы самостоятельно. Для опыта конечно же…, но и, будем честными, денег я зажал на готовые плагины. Надеюсь, что эта статья поможет таким же новичкам в Unity как и я, кто решил учиться разработке через практику и прототипирование. Небольшая оговор очка: я занимаюсь автоматизацией тестирования и мой основной язык python. Так что заранее прошу извинить за не самые лучшие конструкции C#… да и статья не про чистоту кода, а про архитектуру. Ну и последнее:, а что за игру я делаю? Сюжетное 2д приключение, где я решил брать не механиками, а историей.
Часть 1. Планирование архитектуры
Без четкого ТЗ результат ХЗ. Для начала планируем и фиксируем, что вообще хочется реализовать. На моем примере:
Система линейная, диалоги без вариантов ответа;
Каждая сцена — это грубо говоря отдельная игра, которая не связана с другими сценами, их все можно запускать независимо. Сохранения реализованы в момент перехода между сценами;
На каждой сцене есть n стейджей. На каждом стейдже m диалогов. Заканчиваются стейджи — на этой сцене заканчивается сюжет, можно переходить на новую сцену;
Как только происходит переход на новый стейдж, список доступных персонажей для взаимодействия на сцене меняется;
В рамках одного стейджа каждому персонажу на сцене соответствует один диалог;
Диалоги 3х типов: сюжетный, повторяющийся и одноразовый;
Один диалог состоит из: имя говорящего, его анимация и реплика;
Посимвольный вывод текста в диалоговом бабле, несколько видов этого бабла;
Сюжетный диалог всегда меняет сюжетный стейдж на значение +1.
Далее определяемся с тем, в каком виде будут храниться данные о стейджах и диалогах. Сперва я опробовал формат конфигов прям в Unity, но быстро отбросил эту идею. Это оказалось попросту неудобно, особенно, когда диалоги разрастаются, имеют разные типы, соответствуют разным персонажам и т.д.

Затем я подумал, эй, а почему бы не использовать то, с чем я и так работаю много лет? Так выбор пал на гугл таблицы и json«ы. В таблицах я подкрашиваю разными цветами реплики сюжетные, второстепенные, отмечаю себе комментами, где стоит что-то доработать или вообще поменять. Их можно отправить знакомому на вычитку, у кого нет доступа в Unity. Но это удобно лично для меня, так-то прикрутить можно хоть SQLite.
Итак, я создал 3 гугл таблицы:
Описание всех игровых объектов —
ObjectsJson
(это просто названия документов);Описание сюжетных стейджей —
PlotJson
;Описание диалогов —
DialoguesJson
.
В игровых объектах ObjectsJson
получаются следующие столбцы:
charId | charName | sex |
Id персонажа (его название в редакторе) | Текст (Имя для игрока) | Пол персонажа (от этого зависит внешний вид диалогового бабла) |
В стейджах PlotJson
:
stageValue | charId | dialogId | objectName |
Id текущего стейджа. начинается всегда с 1 и увеличивается на +1 по мере прохождения сюжета | Id персонажа, с которым будет начинаться диалог | Id диалога, который будет начинаться с этим персонажем | Здесь указывается словарь из всех прочих персонажей, состояние которых должно меняться. В формате {charid: True}, где True — объект становится активным, False — перестает быть активным (в редакторе включаются или отключаются нужные компоненты) |
И в диалогах DialoguesJson
:
dialogId | dialogTypes | charId | replica | animation |
Id диалога | Тип диалога | Id персонажа | Текст реплики, который будет произнесен персонажем | Анимация, с которой эта реплика будет произнесена |
Как я писал выше, диалог бывает 3х типов:
public enum DialogTypesEnum
{
repeated = 0, // Повторяющийся
onetime = -1, // Одноразовый
plotImportant = 1 // Сюжетный
}
Часть 2. Подготовка конфигов
Таблицы с помощью скриптов я перегоняю в json«ы. Сперва это был питонячий скрипт, но с помощью AI он быстро стал частью C# репозитория.
Как можно парсить таблицу в json с помощью python кода (на примере таблицы по персонажам):
import json
import gspread
from oauth2client.service_account import ServiceAccountCredentials
GOOGLE_SHEET_ID = ""
# Название листа (сцены)
SHEET_NAME = "Russian"
def read_data_from_table_and_make_json(table_data):
data = {}
for row in table_data:
if row['charId']:
if row["charName"] == "":
row["charName"] = "null"
if row["sex"] == "":
row["sex"] = "null"
data[row['charId']] = {
"objectType": row["objectType"],
"charName": row['charName'],
"sex": row['sex'],
"visible": row['visible']
}
json_data = json.dumps(data, ensure_ascii=False, indent=4)
with open("objects_test.json", "w", encoding="utf-8") as file:
json.dump(data, file, ensure_ascii=False, indent=4)
if __name__ == "__main__":
scope = ['https://www.googleapis.com/auth/spreadsheets',
'https://www.googleapis.com/auth/drive']
credentials = ServiceAccountCredentials.from_json_keyfile_name('credentials.json', scope)
client = gspread.authorize(credentials)
sheet = client.open_by_key(GOOGLE_SHEET_ID).worksheet(SHEET_NAME)
data = sheet.get_all_records()
read_data_from_table_and_make_json(data)
Для ускорения разработки скрипты чтения из таблиц срабатывают каждый раз при запуске сцены (т.е. в игре я вижу свежие данные, не перегоняя дополнительно ничего руками). Но так как игра оффлайн, эту фичу к релизу планирую отпилить. Пути к конфигам, учитывая языки, выглядят следующим образом, где BusStation
и HomeScene
названия сцен соответственно:

Про языки и прочие системы разговор отдельный, в рамках данной статьи хочу разобрать исключительно диалоги.
После того, как json’ы готовы и лежат в нужной папке, уже в других скриптах читаем их и подготавливаем данные для дальнейшей обработки. 3 скрипта на чтение 3х таблиц (объекты, диалоги и сюжет), 1 скрипт для управления директориями в зависимости от сцены, которая запущена.
На примере таблицы с диалогами.
1) Прочли json из нужной папки. Где configFileName
— тип конфига. В данном случае это будет string "DialogConfig.json"
:
private TextAsset PrepareConfigJsonFile(string configFileName)
{
string fullFilePath = Path.Combine(Application.dataPath, _additionalFolder, _currentLanguage, _currentSceneName, configFileName);
if (File.Exists(fullFilePath))
{
string allTextFromFile = File.ReadAllText(fullFilePath);
TextAsset fileToJsonFormat = new TextAsset(allTextFromFile);
if (fileToJsonFormat != null)
{
return fileToJsonFormat;
}
Debug.LogError($"Файл некорректного формата json! {fileToJsonFormat}");
return null;
}
Debug.LogError($"Файла не существует! {fullFilePath}");
return null;
}
Подготовили структуру данных диалога:
[System.Serializable]
public class Dialogs
{
public int dialogstatus;
public List phrases;
}
[System.Serializable]
public class ReplicaFromOneDialog
{ // Информация по репликам из одного диалога
public string charId;
public string replica;
public string animation;
}
Получаем данные по dialogId
:
public Dialogs GetDialogDataOnDialogId(string dialogId)
// Возврат данных из диалога по его dialogId
{
_phrasesList = new List();
_dialogData.dialogstatus = _allDialogDataFromJson[dialogId]["dialogStatus"].AsInt;
foreach (var phraseFromJson in _allDialogDataFromJson[dialogId]["phrases"].AsArray)
{
_onePhrase = new ReplicaFromOneDialog();
_onePhrase.charId = phraseFromJson.Value["charId"].Value;
_onePhrase.replica = phraseFromJson.Value["replica"].Value;
_onePhrase.animation = phraseFromJson.Value["animation"].Value;
_phrasesList.Add(_onePhrase);
}
_dialogData.phrases = _phrasesList;
return _dialogData;
}
Эти скрипты выполняются первыми, до загрузки всего остального.
Часть 3. Реализация внутриигровой логики
Конфиги загружены: нужно протаскивать данные дальше в игру! В Unity готовлю отдельным компонентом список всех персонажей, с которыми можно взаимодействовать в рамках сцены. Это сюжетные, второстепенные NPC и сам игрок.

Таким образом зная ID персонажа (его название) я могу получить ссылку на объект. Конечно же такой подход будет не валидным, если персонажи генерятся на ходу и их кол-во заранее не определено, но мне и так норм.
На каждом персонаже висит компонент, который проверяет, что игрок вошел в его зону интересов. Реализовано с помощью OnTriggerEnter2D
и OnTriggerExit2D
.Если игрок в зоне, то над персонажем загорается иконка для взаимодействия, а игрок может начать с ним диалог.
При старте диалога в работу вступает диалоговый контроллер, который щелкает реплики над каждым участником. Вся логика по одноразовым, многоразовым (повторяющимися бесконечно в рамках одного стейджа) и сюжетным диалогам реализована в нем. Он также является точкой входа для других скриптов:
Определение, какой стиль бабла будет отрисован (зависит от пола говорящего);
Посимвольный ввод текста в диалоговый бабл через короутину;
Смена анимации говорящего;
Анимация в бабле, символизирующая о том, что реплика закончена и можно приступать к следующей и т.п.
Тут можно накрутить любые события, которые являются частью активной реплики.
Кроме диалогового контроллера существует общий сюжетный контроллер, который следит за stageValue
изPlotJson
.
Соответственно как только игрок начал диалог с конкретным NPC мы уже знаем все реплики и статус диалога:
_plotContoroller.GetDialogIdOnCharId(_triggeredActiveZoneIndicator._currentCharId);
_dialogController.ShowDialogUsingStatus();
public void ShowDialogUsingStatus()
{
GetDialogIdAndStatusOnPlotStage(); // Получаем всю инфу по первому диалогу
// А дальше уже зависит от того, что это за диалог, в процессе ли он и пр
if (_dialogViewer.IsDialogEnded() && !_dialogViewer.IsDialogActive())
{
GetDialogIdAndStatusOnPlotStage();
}
if (_dialogStatus is (int)DialogTypesEnum.onetime)
{
_dialogViewer.DialogShowReplicas(_dialogId);
if (_dialogViewer.IsDialogEnded() && !_dialogViewer.IsDialogActive())
{
_objectsDisablerEnabler.ChangeObjectTriggerZoneByName(_triggeredActiveZoneIndicator._currentCharId, false);
}
}
else if (_dialogStatus is (int)DialogTypesEnum.repeated)
{
_dialogViewer.DialogShowReplicas(_dialogId);
}
else if (_dialogStatus is (int)DialogTypesEnum.plotImportant)
{
_dialogViewer.DialogShowReplicas(_dialogId);
if (_dialogViewer.IsDialogEnded() && !_dialogViewer.IsDialogActive())
{
_plotController.ChangePlotStatus();
}
}
}
Как только игрок завершит сюжетный диалог, общий игровой стейдж будет увеличен на 1, а вместе с ним изменены состояния объектов, которые были указаны в конфиге:
public void ChangePlotStatus()
{
// Очищаем контейнеры с объектами для включения/отключения с предыдущего стейджа
_objectsDisablerEnabler.ClearContainers();
_currentPlotStatus += 1; // Состояние сюжета всегда меняется на 1 шаг в большую сторону
// Наполняем контейнеры заново, но уже другим значением нового стейджа
_objectsDisablerEnabler.UpdateStageData(_currentPlotStatus);
_objectsDisablerEnabler.ChangeObjectStatus();
}
И так будет происходить до тех пор, пока диалоги в конфиге не закончатся.
А что по визуалу? Вот тут я не вижу смысл что-то дополнительно описывать, так как по этой части гайдов в сети полно. Мне нравится, когда диалог над говорящим с минимумом каких-то визуальных излишеств:

Логика взаимодействует с визуалом через паттерн MVC. Не вижу смысла накручивать здесь что-то сложнее.
И общая схема связей в данной системе:

Как же по итогу все выглядит? Есть у меня для вас небольшая демонстрация:
Хочу напомнить, что это исключительно одна диалоговая система! Без смены языков, сохранения и загрузки, ивентовой системы и прочих механик. Тем, кто дочитал до конца, большущее спасибо! Ну и хочу дополнить, что весь прогресс по проекту пощу в тг канал — https://t.me/volnyiiBAM и буду рад всех там видеть)