Когда я начинал разработку своей игры, то не смог найти каких-то внятных гайдов с описанием архитектуры диалоговой системы. Зачастую авторы упоминали верстку да логику 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<ReplicaFromOneDialog> 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<ReplicaFromOneDialog>(); _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 и буду рад всех там видеть)
ссылка на оригинал статьи https://habr.com/ru/articles/893592/
Добавить комментарий