Большинство игр, используют локализацию по принципу ключа, то есть для описания конкретного текста нужен ключ, я же предлагаю вариант получше, хоть и этот вариант не подходит тем у кого есть озвучка в играх, тут уж проще через ключ.

Что такое ключ и зачем он нужен


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

Как же будет все устроено


В ниже описанных действия будет использован класс статический Lang, в котором и будут происходить все поиски слов/фраз. Давайте же объявим класс, подключив нужные нам библиотеки:

using UnityEngine;
using System.Collections.Generic; // list and dictionary
#if UNITY_EDITOR
using UnityEditor;
using System.IO; // created file in editor
#endif

public class Lang {
    private const string Path = "/Resources/"; // path to resources folder
    private const string FileName = "Language"; // file name with phrases
}

И так, без стандартной библиотеки не обойтись, так как мы будем доставать файл из ресурсов, можно же загружать любым удобным способом, но так удобнее и практичнее. Системные библиотеки нужны для подключения списка, словаря и работы с файлами из редактора. Библиотека UnityEditor нужна только для обновления файла во время первой записи фразы, т.к. после быстрого перезапуска не всегда будут загружены все фразы, но с помощью данной библиотеки мы сможем решить данную проблему. Класс хранит два статических поля, это пусть и названия файла, в нашем же случае путь — это папка с ресурсами, а названия файла может быть любое.

Теперь нужно добавить списки для хранения все используемых языком и словарь.

   
    private static int LangIndex; // variable to store the index of the current language 
    private static List<SystemLanguage> languages = new List<SystemLanguage>(); // having languages in game
    private static Dictionary<string, string> Phrases = new Dictionary<string, string>(); // keys and values

Поле LangIndex будет держать индекс текущего языка относительно записи в файле. В списке languages — будут записаны все языки используемые в файле. Словарь же будет хранить все фразы на основном языке и на текущем языке.

Нужно добавить инициализацию выше описанных полей класса.

Код

    public static bool isStarting // bool for check starting
    {
        get; private set;
    }

    public static SystemLanguage language // return current language
    {
        get; private set;
    }

#if UNITY_EDITOR
    public static void Starting(SystemLanguage _language, SystemLanguage default_language = SystemLanguage.English, params SystemLanguage[] _languages) // write languages without main language, it self added
#else
    public static void Starting(SystemLanguage _language = SystemLanguage.English) // main language - only for compilation
#endif
    {
#if UNITY_EDITOR
        if (!File.Exists(Application.dataPath + Path + FileName + ".csv")) // if file wasn't created
        {
            File.Create(Application.dataPath + "/Resources/" + FileName + ".csv").Dispose(); // create and lose link
            File.WriteAllText(Application.dataPath + "/Resources/" + FileName + ".csv", SetLanguage(default_language, _languages)); // write default text with index
        }
#endif
        string[] PhrasesArr = Resources.Load<TextAsset>(FileName).text.Split('\n'); // temp var for write in dicrionary

        string[] string_languages = PhrasesArr[0].Split(';'); // string with using languages
        int _length = string_languages.Length - 1;
        for (int i = 0; i < _length; i++)
        {
            languages.Add(SystemLanguageParse(string_languages[i])); // string language to SystemLanguage
        }

        LangIndex = FindIndexLanguage(_language); // index with current language
        for (int i = 0; i < PhrasesArr.Length; i++) // add keys and value
        {
            string[] temp_string = PhrasesArr[i].Split(';');
            if (temp_string.Length > LangIndex)
                Phrases.Add(temp_string[0], temp_string[LangIndex]);
            else Phrases.Add(temp_string[0], temp_string[0]);
        }
        isStarting = true;
    }

Будет сразу использовать встроенный директивы да бы не делать лишних действий после компиляции приложения. Вызов Lang.Starting(...) должен происходит примерно таким образом:

#if !UNITY_EDITOR
        Lang.Starting(LANGUAGE);
#else
        Lang.Starting(LANGUAGE, SystemLanguage.English, SystemLanguage.Russian, SystemLanguage.Ukrainian);
#endif
    private static int FindIndexLanguage(SystemLanguage _language) // finding index or current language
    {
        int _index = languages.IndexOf(_language); 
        if (_index == -1) // if language not found
            return 0; // return main language
        return _index;
    }

#if UNITY_EDITOR
    private static void Add(string AddString) // add phrases only form editor
    {
        File.AppendAllText(Application.dataPath + "/Resources/" + FileName +".csv", AddString + "\n"); // rewrite text to file
        Phrases.Add(AddString, AddString); // add phrase to dicrionary

        AssetDatabase.Refresh(); // refresh file 
    }
#endif

#if UNITY_EDITOR
    private static string SetLanguage(SystemLanguage default_language, params SystemLanguage[] _languages) // set first string to file
    {
        string ret_string = "";
        ret_string += default_language + ";";
        foreach (SystemLanguage _language in _languages)
        {
            ret_string += _language + ";";
        }
        return ret_string + "!@#$%\n"; // for last index
    }
#endif


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

В коде, приведенном выше, используется методSystemLanguageParse(...) который просто переводит названия языка из строкового типа в SystemLanguage (данный метод будет ниже).

Остановимся на методе добавления:

#if UNITY_EDITOR
    private static void Add(string AddString) // add phrases only form editor
    {
        File.AppendAllText(Application.dataPath + "/Resources/" + FileName +".csv", AddString + "\n"); // rewrite text to file
        Phrases.Add(AddString, AddString); // add phrase to dicrionary

        AssetDatabase.Refresh(); // refresh file 
    }
#endif

По скольку этот метод будет использоваться только при запуске из редактора, мы спокойно может использовать системную утилиту для перезаписи файла, а так же обновлять измененные файлы редакторе с помощью метода Refresh(). Между этими действиями просто добавляться в словарь фраза, да бы уберечь себя от повторной записи в этой же сесии.

Кстати, забыл сказать, фразы будут храниться в файле .csv, что позволит нам комфортно делать переводы фраз в Excele. Теперь нужно добавить хороший для нас метод, который позволит сменить язык:

    public static void ChangeLanguage(SystemLanguage _language) // change language
    {
        string[] PhrasesArr = Resources.Load<TextAsset>(FileName).text.Split('\n'); // load all text from file

        LangIndex = FindIndexLanguage(_language);
        Phrases.Clear(); // clear dictionary with phrases
        for (int i = 1; i < PhrasesArr.Length; i++)
        {
            string[] temp_string = PhrasesArr[i].Split(';');
            if (temp_string.Length > LangIndex)
                Phrases.Add(temp_string[0], temp_string[LangIndex]);
            else Phrases.Add(temp_string[0], temp_string[0]);
        }
    }

И так, подошли к самому главному методу, который и будет принимать фразу на основном языке, и выдавать на нужном пользователю:

    public static string Phrase(string DefaultPhrase) // translate phrase, args use to formating string
	{
#if UNITY_EDITOR
        if (!isStarting) // if not starting
        {
            throw new System.Exception("Forgot initialization.Use Lang.Starting(...)"); // throw exception
        }
#endif
        string temp_EnglishPhrase = DefaultPhrase; // temp variable for try get value

        if (Phrases.TryGetValue(DefaultPhrase, out DefaultPhrase)) // if value has been found
        {
            return temp_EnglishPhrase;
        }
#if UNITY_EDITOR
        Add(temp_EnglishPhrase); // add phrase if value hasn't been found
#endif
            return temp_EnglishPhrase;
    }

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

string str = Lang.Phrase("Hello world");

Теперь в строку str попадет фраза на нужном нам языке, если вдруг он она отсутствует то попадет фраза указанная в параметрах, то есть Hello world.

Этот метод можно немного улучшить, да бы можно было принимать аргументы для заполнения:

    public static string Phrase(string DefaultPhrase, params string[] args) // translate phrase, args use to formating string
	{
#if UNITY_EDITOR
        if (!isStarting) // if not starting
        {
            throw new System.Exception("Forgot initialization.Use Lang.Starting(...)"); // throw exception
        }
#endif
        string temp_EnglishPhrase = DefaultPhrase; // temp variable for try get value

        if (Phrases.TryGetValue(DefaultPhrase, out DefaultPhrase)) // if value has been found
        {
            if (args.Length == 0)
                return DefaultPhrase;
            return string.Format(DefaultPhrase, args);
        }
#if UNITY_EDITOR
        Add(temp_EnglishPhrase); // add phrase if value hasn't been found
#endif
        if (args.Length == 0)
            return temp_EnglishPhrase;
        return string.Format(temp_EnglishPhrase, args);
    }

Теперь этот метод можно вызвать так же как и раньше:

string str = Lang.Phrase("Hello world");

Но теперь у нашого метода есть форматированный вывод, указав через запятую параметры:

string str = Lang.Phrase("Hello {0} from {1}", "world", "habr");

Переводы фраз


Как я уже писал выше, файл использует разширения .csv что позволит делать все в екселе, но не все так просто, проблема си-шарпа и екселя в том, что они понимают кириллицу в разных кодировка, ексель понимает только кодировку UTF-8-BOM или же ту, которую не понимает наш ЯП, мы же должны использовать в нем только UTF-8, хоть юнитовский редактор будет понимать UTF-8-BOM, в коде же два одинаковых слова на разных кодировках (UTF-8 и UTF-8-BOM) будут не равны, что приведет к постоянному добавлению одинаковых слов в наш файл.

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

Итоговый код
using UnityEngine;
using System.Collections.Generic; // list and dictionary
#if UNITY_EDITOR
using UnityEditor;
using System.IO; // created file in editor
#endif

public class Lang
{

    private const string Path = "/Resources/"; // path to resources folder
    private const string FileName = "Language"; // file name with phrases

    private static int NumberOfLanguage; // variable to store the index of the current language 
    private static List<SystemLanguage> languages = new List<SystemLanguage>(); // having languages in game
    private static Dictionary<string, string> Phrases = new Dictionary<string, string>(); // keys and values
    private static SystemLanguage language; // current language
#if UNITY_EDITOR
    public static void Starting(SystemLanguage _language, SystemLanguage default_language, params SystemLanguage[] _languages) // write languages without main language, it self added
#else
    public static void Starting(SystemLanguage _language = SystemLanguage.English) // main language - only for compilation
#endif
    {
#if UNITY_EDITOR
        if (!File.Exists(Application.dataPath + Path + FileName + ".csv")) // if file wasn't created
        {
            File.Create(Application.dataPath + "/Resources/" + FileName + ".csv").Dispose(); // create and lose link
            File.WriteAllText(Application.dataPath + "/Resources/" + FileName + ".csv", SetLanguage(default_language, _languages)); // write default text with index
        }
#endif
        string[] PhrasesArr = Resources.Load<TextAsset>(FileName).text.Split('\n'); // temp var for write in dicrionary

        string[] string_languages = PhrasesArr[0].Split(';'); // string with using languages
        int _length = string_languages.Length - 1;
        for (int i = 0; i < _length; i++)
        {
            languages.Add(SystemLanguageParse(string_languages[i])); // string language to SystemLanguage
        }

        NumberOfLanguage = FindIndexLanguage(_language); // index with current language
        for (int i = 0; i < PhrasesArr.Length; i++) // add keys and value
        {
            string[] temp_string = PhrasesArr[i].Split(';');
            if (temp_string.Length > NumberOfLanguage)
                Phrases.Add(temp_string[0], temp_string[NumberOfLanguage]);
            else Phrases.Add(temp_string[0], temp_string[0]);
        }
        isStarting = true;
    }

    public static bool isStarting // bool for check starting
    {
        get; private set;
    }

    public static SystemLanguage Language // return current language
    {
        get { return language; }
    }

    public static string Phrase(string DefaultPhrase, params string[] args) // translate phrase, args use to formating string
    {
#if UNITY_EDITOR
        if (!isStarting) // if not starting
        {
            throw new System.Exception("Forgot initialization.Use Lang.Starting(...)"); // throw exception
        }
#endif
        string temp_EnglishPhrase = DefaultPhrase; // temp variable for try get value

        if (Phrases.TryGetValue(DefaultPhrase, out DefaultPhrase)) // if value has been found
        {
            if (args.Length == 0)
                return DefaultPhrase;
            return string.Format(DefaultPhrase, args);
        }
#if UNITY_EDITOR
        Add(temp_EnglishPhrase); // add phrase if value hasn't been found
#endif
        if (args.Length == 0)
            return temp_EnglishPhrase;
        return string.Format(temp_EnglishPhrase, args);
    }

    public static void ChangeLanguage(SystemLanguage _language) // change language
    {
        string[] PhrasesArr = Resources.Load<TextAsset>(FileName).text.Split('\n'); // load all text from file

        NumberOfLanguage = FindIndexLanguage(_language);
        Phrases.Clear(); // clear dictionary with phrases
        for (int i = 1; i < PhrasesArr.Length; i++)
        {
            string[] temp_string = PhrasesArr[i].Split(';');
            if (temp_string.Length > NumberOfLanguage)
                Phrases.Add(temp_string[0], temp_string[NumberOfLanguage]);
            else Phrases.Add(temp_string[0], temp_string[0]);
        }
    }

    private static int FindIndexLanguage(SystemLanguage _language) // finding index or current language
    {
        int _index = languages.IndexOf(_language);
        if (_index == -1) // if language not found
            return 0; // return main language
        return _index;
    }

#if UNITY_EDITOR
    private static void Add(string AddString) // add phrases only form editor
    {
        File.AppendAllText(Application.dataPath + "/Resources/" + FileName + ".csv", AddString + "\n"); // rewrite text to file
        Phrases.Add(AddString, AddString); // add phrase to dicrionary

        AssetDatabase.Refresh(); // refresh file 
    }
#endif

#if UNITY_EDITOR
    private static string SetLanguage(SystemLanguage default_language, params SystemLanguage[] _languages) // set first string to file
    {
        string ret_string = "";
        ret_string += default_language + ";";
        foreach (SystemLanguage _language in _languages)
        {
            ret_string += _language + ";";
        }
        return ret_string + "!@#$%\n"; // for last index
    }
#endif
    private static SystemLanguage SystemLanguageParse(string _language) // just parse from string to SystemLanguage
    {
        switch (_language)
        {
            case "English": return SystemLanguage.English;
            case "Russian": return SystemLanguage.Russian;
            case "Ukrainian": return SystemLanguage.Ukrainian;
            case "Polish": return SystemLanguage.Polish;
            case "French": return SystemLanguage.French;
            case "Japanese": return SystemLanguage.Japanese;
            case "Chinese": return SystemLanguage.Chinese;
            case "Afrikaans": return SystemLanguage.Afrikaans;
            case "Arabic": return SystemLanguage.Arabic;
            case "Basque": return SystemLanguage.Basque;
            case "Belarusian": return SystemLanguage.Belarusian;
            case "Bulgarian": return SystemLanguage.Bulgarian;
            case "ChineseSimplified": return SystemLanguage.ChineseSimplified;
            case "ChineseTraditional": return SystemLanguage.ChineseTraditional;
            case "Czech": return SystemLanguage.Czech;
            case "Danish": return SystemLanguage.Danish;
            case "Dutch": return SystemLanguage.Dutch;
            case "Estonian": return SystemLanguage.Estonian;
            case "Faroese": return SystemLanguage.Faroese;
            case "Finnish": return SystemLanguage.Finnish;
            case "German": return SystemLanguage.German;
            case "Greek": return SystemLanguage.Greek;
            case "Hebrew": return SystemLanguage.Hebrew;
            case "Hungarian": return SystemLanguage.Hungarian;
            case "Icelandic": return SystemLanguage.Icelandic;
            case "Indonesian": return SystemLanguage.Indonesian;
            case "Italian": return SystemLanguage.Italian;
            case "Korean": return SystemLanguage.Korean;
            case "Latvian": return SystemLanguage.Latvian;
            case "Lithuanian": return SystemLanguage.Lithuanian;
            case "Norwegian": return SystemLanguage.Norwegian;
            case "Portuguese": return SystemLanguage.Portuguese;
            case "Romanian": return SystemLanguage.Romanian;
            case "SerboCroatian": return SystemLanguage.SerboCroatian;
            case "Slovak": return SystemLanguage.Slovak;
            case "Slovenian": return SystemLanguage.Slovenian;
            case "Spanish": return SystemLanguage.Spanish;
            case "Swedish": return SystemLanguage.Swedish;
            case "Thai": return SystemLanguage.Thai;
            case "Turkish": return SystemLanguage.Turkish;
            case "Vietnamese": return SystemLanguage.Vietnamese;
        }
        return SystemLanguage.Unknown;
    }
}

Главное запомните: UTF-8-BOM — для работы в Excel, UTF-8 для работы кодом, не забудьте.

Комментарии (19)


  1. adictive_max
    05.04.2019 10:48
    +4

    Орден почётного члена нашего велоклуба этому господину.
    Это, собственно, самый простой и прямолинейный способ локализации, который только бывает. По сути, тот же словарь, только с оригиналом фразы в качестве ключа. Причём в вашей реализации даже множественный формы не поддерживаются, в отличие от gettext, которому уже 30 лет и который портирован, наверное, даже на brainfuck.
    Основной недостаток у такого подхода — переводы отваливаются, если поменяется ключевая фраза на дефолтном языке.


    1. kitsoRik Автор
      05.04.2019 21:29

      Да, это самый простой, но какой-то новичек которому нужно будет легкий перевод фраз, не откажеться от столь простого решения.
      На счет множественных форм, вполне можно обойтись и аргументами вместо {0},{1]..., самому прописать конечно же.
      Если же фраза поменяеться, то можно и поменять в файле с языками.


    1. roscomtheend
      08.04.2019 12:13

      А мне понравился лёгкий налёт танцев и песен от создателей "Зиты и Гиты" в виде замены TryParse на switch. Есть в этом какая-то незамутнённость и желание побегать по граблям при изменении enum'а (каюсь, остальной код внимательно не осилил, хватило основной идеи, чтобы сразу пролистать до комментариев).


      PS. Глянул ещё — постоянное конструирование строк с чередованием константы с её значением тоже выглядит очень мило.


      Для автора


      все же ексель не умеет читать кирилицу в UTF-8, а в UTF-8 BOM умеет

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


  1. roman-ilyin
    05.04.2019 11:45

    progamedev.net/localization вот тут мы с Сашей описали основные проблемы локализации. не все, но Ваш подход точно соберет бОльшую часть граблей. :)


  1. lair
    05.04.2019 12:26

    в коде же два одинаковых слова на разных кодировках (UTF-8 и UTF-8-BOM) будут не равны

    А, что, как? Что такое "слово в кодировке UTF-8-BOM" в коде C#? Вы правда не сумели заставить .net-производное прочитать файл с BOM?


    1. kitsoRik Автор
      05.04.2019 21:40

      Честно говоря, из-за этого были проблемы, почему-то не всегда считывало, иногда при сравнении слова в BOM и константы выдавало false, на всякий что бы, если бы вдруг не работало, не думали что проблема в коде, а наверное в кодировке.


      1. lair
        05.04.2019 21:45

        Что такое "слово в BOM"? Что вообще такое BOM, по вашему?


        1. kitsoRik Автор
          05.04.2019 21:49

          Для меня это как бы та же кодировка что и UTF8, только без маркера.


          1. lair
            05.04.2019 21:52

            Вы не ответили ни на один из двух вопросов.


            1. kitsoRik Автор
              05.04.2019 21:54

              Формально ответ на второй вопрос был, ответ на первый исходил из второго, «слово в ВОМ» для меня это слово в другой кодировке, а точнее слова, т.к. один файл — одна кодировка, ВОМ — это в вашем подтексте было сокращения UTF8-BOM


              1. lair
                05.04.2019 22:03

                Формально ответ на второй вопрос был

                Неа, не было. Давайте еще раз. Что такое BOM?


                1. kitsoRik Автор
                  06.04.2019 14:29

                  Маркер последовательности байтов или метка порядка байтов (англ. Byte Order Mark, BOM)


                  1. lair
                    06.04.2019 14:31

                    Ура. Что такое "слово в BOM"?


                    1. kitsoRik Автор
                      06.04.2019 15:01

                      Слово имеющее кодировку — UTF8-BOM, т.е. — Маркер последовательности байтов или метка порядка байтов (англ. Byte Order Mark, BOM)


                      1. lair
                        06.04.2019 15:01

                        Что такое "кодировка UTF8-BOM"?


                        1. kitsoRik Автор
                          06.04.2019 15:06

                          Это кодировка в которой четвертый байт содержит информацию о следующим


                          1. lair
                            06.04.2019 15:06

                            Нет такой кодировки. Просто нет. Вообще нет кодировки UTF-8-BOM, есть просто UTF-8.


                            1. kitsoRik Автор
                              06.04.2019 15:19

                              Да, вы будете правы, но черт побери, все же ексель не умеет читать кирилицу в UTF-8, а в UTF-8 BOM умеет


                              1. lair
                                06.04.2019 15:21

                                все же ексель не умеет читать кирилицу в UTF-8, а в UTF-8 BOM умеет

                                Excel не умеет читать файлы в UTF-8, не имеющие BOM. Ну да, бывает. Это не отменяет того, что файлы имеют одну и ту же кодировку вне зависимости от BOM. И, что важнее, .net умеет читать файлы как с BOM, так и без, а в памяти прочитанные строки всегда представляются одинаково — в UTF-8.