Архитектура диалоговой системы в Unity

Когда я начинал разработку своей игры, то не смог найти каких-то внятных гайдов с описанием архитектуры диалоговой системы. Зачастую авторы упоминали верстку да логику UI, но не отвечали на вопросы «как менять сюжетные стейджи», «как работать с разными типами диалогов», «как менять статус персонажам на сцене» и т.п. Мне не хватало найденной информации и я потратил какое-то время на написание диалоговой системы самостоятельно. Для опыта конечно же…, но и, будем честными, денег я зажал на готовые плагины. Надеюсь, что эта статья поможет таким же новичкам в Unity как и я, кто решил учиться разработке через практику и прототипирование. Небольшая оговор очка: я занимаюсь автоматизацией тестирования и мой основной язык python. Так что заранее прошу извинить за не самые лучшие конструкции C#… да и статья не про чистоту кода, а про архитектуру. Ну и последнее:, а что за игру я делаю? Сюжетное 2д приключение, где я решил брать не механиками, а историей.

Часть 1. Планирование архитектуры

Без четкого ТЗ результат ХЗ. Для начала планируем и фиксируем, что вообще хочется реализовать. На моем примере:

  • Система линейная, диалоги без вариантов ответа;

  • Каждая сцена — это грубо говоря отдельная игра, которая не связана с другими сценами, их все можно запускать независимо. Сохранения реализованы в момент перехода между сценами;

  • На каждой сцене есть n стейджей. На каждом стейдже m диалогов. Заканчиваются стейджи — на этой сцене заканчивается сюжет, можно переходить на новую сцену;

  • Как только происходит переход на новый стейдж, список доступных персонажей для взаимодействия на сцене меняется;

  • В рамках одного стейджа каждому персонажу на сцене соответствует один диалог;

  • Диалоги 3х типов: сюжетный, повторяющийся и одноразовый;

  • Один диалог состоит из: имя говорящего, его анимация и реплика;

  • Посимвольный вывод текста в диалоговом бабле, несколько видов этого бабла;

  • Сюжетный диалог всегда меняет сюжетный стейдж на значение +1.

Далее определяемся с тем, в каком виде будут храниться данные о стейджах и диалогах. Сперва я опробовал формат конфигов прям в Unity, но быстро отбросил эту идею. Это оказалось попросту неудобно, особенно, когда диалоги разрастаются, имеют разные типы, соответствуют разным персонажам и т.д.

3f7c06c7c074256b59ef16e41d859754.png

Затем я подумал, эй, а почему бы не использовать то, с чем я и так работаю много лет? Так выбор пал на гугл таблицы и json«ы. В таблицах я подкрашиваю разными цветами реплики сюжетные, второстепенные, отмечаю себе комментами, где стоит что-то доработать или вообще поменять. Их можно отправить знакомому на вычитку, у кого нет доступа в Unity. Но это удобно лично для меня, так-то прикрутить можно хоть SQLite.

Итак, я создал 3 гугл таблицы:

  1. Описание всех игровых объектов — ObjectsJson (это просто названия документов);

  2. Описание сюжетных стейджей — PlotJson;

  3. Описание диалогов — 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 названия сцен соответственно:

d16ce606c284ac3bbc7d15cdfcd71dc9.png

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

После того, как 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 и сам игрок.

60e70e92341e4a95f91b51697c9c7aef.png

Таким образом зная 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();
}

И так будет происходить до тех пор, пока диалоги в конфиге не закончатся.

А что по визуалу? Вот тут я не вижу смысл что-то дополнительно описывать, так как по этой части гайдов в сети полно. Мне нравится, когда диалог над говорящим с минимумом каких-то визуальных излишеств:

b2b3abc4f585a8f2f1c60e613dca352c.png

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

И общая схема связей в данной системе:

f1c1f74ba6bf81d7e82d97233b852caf.png
Sign up | Miro | Online Whiteboard for Visual Collaboration
miro.com

Как же по итогу все выглядит? Есть у меня для вас небольшая демонстрация:

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

© Habrahabr.ru