Gamedev sarcastic hand
556

POchёm локализация?

Вступительные слова.

В закладки

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

Как вы уже поняли, эта статья про локализацию. И как вы возможно ещё не поняли, но сейчас поймете, это статья продолжение вот этой статьи.

Ставятся ли какие-то цели в данной статье? Простым ответом как и ранее будет - нет, никаких целей (нет целей - нет ответственности), но мы можем посмотреть как люди пытаются решить ту или иную проблему в геймдеве и что у них из всего этого вышло.

Теория.

Отвечая на главный вопрос статьи, можно сказать что локализация в Dune Legacy достается практически бесплатно, так как проект использует для локализации проект gettext (косвенно) и используемый им формат файла "PO" и "POT"(явно).

Общее представление о том, как работает gettext можно почерпнуть из Wiki, более подробное - из документации. Получилось лаконично, а не как в прошлый раз.

Код.

Посмотрим два файла POFile.h и POFile.cpp. В первом вы видим объявление функции loadPOFile комментариев к ней нет, но название в принципе дает понять что она делает, ей передается SDL_RWops в виде указателя, булева переменная freesrc установленная по умолчанию в true которая нужна непонятно для чего, ну и константная ссылка на строку с именем filename со значением по умолчанию в виде пустой строки. Возвращает эта функция std::map<std::string, std::string>. Больше в этом заголовочном файле ничего нет. Смотрим POFile.cpp, вот в нем то мы может чего интересного и найдем. Если посмотреть секцию файла POFile.cpp где происходит подключение заголовочных файлов, там можно обнаружить вот такую строку:

#include <misc/string_util.h>

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

static std::string unescapeString(const std::string& str) { std::map<std::string, std::string> replacementMap; replacementMap["\\0"] = "\0"; replacementMap["\\n"] = "\n"; replacementMap["\\r"] = "\r"; replacementMap["\\t"] = "\t"; replacementMap["\\a"] = "\a"; replacementMap["\\b"] = "\b"; replacementMap["\\?"] = "\?"; replacementMap["\\\\"] = "\\"; replacementMap["\\\""] = "\""; replacementMap["\\\'"] = "\'"; return replaceAll(str, replacementMap); }

Что она тут делает? Лично мне не понятно, но она тут. Хотя она достаточно универсальна чтобы быть в string_utils.cpp. Возможно в новом релизе игры мы её там и увидим. По названию и содержанию это функция дезэкранирования строки. Следующая функция - функция извлечения строки extractString():

static std::string extractString(const std::string& str, const std::string& filename, int lineNum) { size_t firstQuote = str.find_first_of('\"'); size_t lastQuote = str.find_last_of('\"'); if(firstQuote == std::string::npos || lastQuote == std::string::npos) { SDL_Log("%s:%d: Missing opening or closing quotes!", filename.c_str(), lineNum); return ""; } return convertUTF8ToISO8859_1(unescapeString(str.substr(firstQuote+1, lastQuote-firstQuote-1))); }

Как понятно из названия - это функция извлечения строки. Функция реализована достаточно просто, находим первое вхождение открывающей кавычки, находим последнее вхождение закрывающей кавычки (да, если вы напишете что-то типа ' привет " я поломанная строка" ', то захватит увы только 'я поломанная строка', но с другой стороны тут уж вы виноваты сами) и после этого проверяем чтобы firstQuote или lastQuote не были std::string::npos, а если вдруг это так, то мы об этом пишем в лог, возвращаем пустую строку и "тикаем з сила". Если же всё хорошо, берем подстроку от firstQuote+1 и до lastQuote-firstQuote-1, то что получилось пропускаем через unescapeString и конвертируем из UTF8 в ISO8859_1 попутно вернув получившийся результат из функции. Ну а теперь посмотрим вишенку на торте этих двух файлов - loadPOFile():

std::map<std::string, std::string> loadPOFile(SDL_RWops* rwop, bool freesrc, const std::string& filename) { std::map<std::string, std::string> mapping; if(rwop == nullptr) { SDL_Log("%s: Cannot find this file!", filename.c_str()); return mapping; } std::string msgid; std::string msgstr; bool msgidMode = false; bool msgstrMode = false; int lineNum = 0; bool bFinished = false; while(!bFinished) { lineNum++; std::string completeLine; unsigned char tmp; while(1) { size_t readbytes = SDL_RWread(rwop,&tmp,1,1); if(readbytes == 0) { bFinished = true; break; } else if(tmp == '\n') { break; } else if(tmp != '\r') { completeLine += tmp; } } size_t lineStart = completeLine.find_first_not_of(" \t"); if(lineStart == std::string::npos || completeLine[lineStart] == '#') { // blank line or comment line continue; } if(completeLine.substr(lineStart, 5) == "msgid") { if(msgidMode == true) { SDL_Log("%s:%d: Opening a new msgid without finishing the previous one!", filename.c_str(), lineNum); } else if(msgstrMode == true) { // we have finished the previous translation mapping[msgid] = msgstr; msgid = ""; msgstr = ""; msgstrMode = false; } msgid = extractString(completeLine.substr(lineStart + 5), filename, lineNum); msgidMode = true; } else if(completeLine.substr(lineStart, 6) == "msgstr") { msgidMode = false; msgstr = extractString(completeLine.substr(lineStart + 6), filename, lineNum); msgstrMode = true; } else { if(msgidMode) { msgid += extractString(completeLine, filename, lineNum); } else if(msgstrMode) { msgstr += extractString(completeLine, filename, lineNum); } } } if(msgstrMode == true) { // we have a last translation to finish mapping[msgid] = msgstr; } if(freesrc) { SDL_RWclose(rwop); } return mapping; }

Чтобы понять как работает эта функция вы должны были прочитать про формат PO файлов, но вы же этого не сделали да? ) Вам же хуже :3.А ещё помните freesrc этот загадочный аргумент функции loadPOFile, а оказалось это просто флаг для закрытия открытого SDL_RWops по выходу из функции (сомнительный конечно способ освобождения ресурсов, но что есть).

- Ну что погнали?!

Функция loadPOFile() представляет из себя функцию заполняющую ассоциативный массив std::map<std::string, std::string> строками из PO файла в формате msgid = msgstr. Для этого в начале мы определяем переменную mapping являющуюся этим ассоциативным массивом. Проверяем переданный SDL_RWops на то, что он не нулевой указатель. если все же нулевой, просто возвращаем mapping. Далее определяются 6 переменных, манипулируя которыми из PO файла собирается ассоциативный массив. Строковые msgid и msgstr это то, чем мы заполняем массив. Две переменные выставленные в false это булевы переменные msgidMode и msgstrMode, помогающие контролировать поток выполнения внутри функции и заполнения массива в соответствии с тем какой режим будет активен. Целочисленная переменная lineNum указывает на номер строки в разбираемом PO файле. И последняя булева перемена bFinished используется как флаг прекращения бесконечного цикла разбора. Ниже идет этот самый цикл разбора. Сначала производится инкремент lineNum, потому как мы не можем иметь нулевую строку. Потом определяются ещё две переменные одна получения результирующей строки - completeLine, вторая для временного хранения символа строки, которую мы читаем побайтно за раз из SDL_RWops - tmp. А далее идет ещё один бесконечный цикл, в котором мы пытаемся заполнить completeLine. После того как мы заполнили completeLine мы пытается найти в ней то место которое начинается не с табуляции. Если такого места нет или позиция на которую указывает это место это символ # , то мы переходим к началу цикла потому что то, что мы нашли это либо пустая строка, либо комментарий. Далее после того как мы присвоили эту позицию lineStart, мы можем начать заполнять массив. Берем подстроку в completeLine начиная от lineStart вперед на 5 символов и сравниваем ее cо строкой "msgid", если мы и правда наткнулись на msgid, проверяем выставлен ли msgidMode в true, если и это так записываем лог. Если же msgidMode не true проверяем msgstrMode. Если последний в true это значит что мы находимся на следующей паре "msgid + msgstr" и следовательно пора записать в массив значение msgstr для его msgid, об этом нам так же подсказывает комментарий к коду записи. После обновляем переменные msgid и msgstr пустыми строками и выставляем msgstrMode в false. Возвращаясь назад к потому до того момента когда бы проверяем msgstrMode, если он оказывается false, но при этом msgid == true, то это означает что мы находимся на той стадии разбора пары "msgid + msgstr", когда мы ещё не дошли до "msgstr," но уже должны прочитать "msgid", что мы и делаем таким кодом:

msgid = extractString(completeLine.substr(lineStart + 5), filename, lineNum);

Если вы подзабыли, то extractString выбирает строку из кавычек. И выставляем msgidMode в true.

Одну ветку расписали. Вторая ветка рассматривает вариант если подстрока на которую мы наткнулись начиная от startLine является "msgstr". Тут уже попроще, устанавливаем msgid в false, извлекаем подстроку из кавычек и присваиваем переменной msgstr и устанавливаем режим msgstrMode в true.

Последняя альтернативная ветка разбора - это ветка, в которой мы учитываем тот случай, когда мы находимся в конце разбора и нам нужно обработать последнюю пару "msgid + msgstr". После идет дополнительный код для занесения последней пары в массив и код закрытия ресурса в виде открытого файла посредством SDL_RWops.

На самом деле выглядит немного запутанно, но если запустить это дело под отладчиком или просто нарисовать любыми подручными средствами, всё становится предельно ясно - попробуйте! А у меня на этот раз всё.

Резюме.

Хотелось бы написать тут что-нибудь толковое, но на самом деле пока особо нечего. Это не последняя статья про локализацию в Dune Legacy, также как и предыдущая статья не последняя статья по pak файлам, т.к. нужно еще показать код, который будет использовать всё это добро.

Если вы небольшой разработчик, присматривайтесь к таким проектам как gettext и им подобным, это дешевый способ получить многое, за сравнительно небольшие временные и денежные вложения, тем более что даже оригинальная библиотека gettext распространяется под LGPL лицензией. Хотя авторы ремейка и решили вообще самостоятельно сделать разбор PO файлов в минимальном варианте (gettext предлагает больше).

Материал опубликован пользователем. Нажмите кнопку «Написать», чтобы поделиться мнением или рассказать о своём проекте.

Написать
{ "author_name": "sarcastic hand", "author_type": "self", "tags": ["include"], "comments": 4, "likes": 8, "favorites": 21, "is_advertisement": false, "subsite_label": "gamedev", "id": 43276, "is_wide": false, "is_ugc": true, "date": "Thu, 21 Mar 2019 15:18:35 +0300" }
{ "id": 43276, "author_id": 118955, "diff_limit": 1000, "urls": {"diff":"\/comments\/43276\/get","add":"\/comments\/43276\/add","edit":"\/comments\/edit","remove":"\/admin\/comments\/remove","pin":"\/admin\/comments\/pin","get4edit":"\/comments\/get4edit","complain":"\/comments\/complain","load_more":"\/comments\/loading\/43276"}, "attach_limit": 2, "max_comment_text_length": 5000, "subsite_id": 64954, "last_count_and_date": null }

4 комментария 4 комм.

Популярные

По порядку

4

Локализация может дело и третье, но если вы в принципе планируете переводить игру на другие языки в хоть сколько-то обозримом будущем (неважно каким способом), то этим лучше в любом случае озадачиться заранее, на этапе написания кода. Потому что потом это все перелопачивать и вмещать невмещаемое в строгие рамки отнимет в три раза больше времени и нервов, а также негативно скажется на качестве продукта, планируемого на другие рынки.

Ответить
0

Могу согласиться лишь в том, что всё индивидуально. Тот же gettext тем и хорош, что интегрируется в код очень просто, и ничего особо "перелопачивать" не надо. С оговоркой на то, что под локализацией понимается перевод текстовой информации. Со звуком конечно всё намного сложнее. Вообще в принципе локализация довольно объемная и непростая тема... я не хотел создать впечатление, что это просто, а лишь показал как это решали создатели ремейка и на мой взгляд это достаточно интересный подход.

Ответить
1

Я, конечно, не эксмперт, но мне всё же кажется, что техническая часть - наименьшая из проблем в локализации. Особенно если разработчик занимается ей сам и у него есть незапакованные исходники, то он может просто поменять их и заново запаковать с новым текстом.

Ответить
1

С текстом все относительно просто в этом вы совершенно правы. Сложности возникают тогда, когда вы например хотите чтобы локализованный звук попадал в мимику анимации + субтитры не отставали от этого дела + желательно чтобы все это динамически подгонялось при просадках в fps, но так чтобы это было незаметно на слух или глаз (как пример достаточно сложной реализации механизма локализации в игре). Не говоря уже действительно о сложности перевода и о том что фраза на одном языке может быть более короткой и лаконичной чем на другом и наоборот. И это все помимо такой вкусовщины, как "этот голос не подходит к этому персонажу". В общем-то проблем достаточно.

Ответить
0

Прямой эфир

[ { "id": 1, "label": "100%×150_Branding_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox_method": "createAdaptive", "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfl" } } }, { "id": 2, "label": "1200х400", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfn" } } }, { "id": 3, "label": "240х200 _ТГБ_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fizc" } } }, { "id": 4, "label": "240х200_mobile", "provider": "adfox", "adaptive": [ "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "flbq" } } }, { "id": 5, "label": "300x500_desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "ezfk" } } }, { "id": 6, "label": "1180х250_Interpool_баннер над комментариями_Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "ffyh" } } }, { "id": 7, "label": "Article Footer 100%_desktop_mobile", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjxb" } } }, { "id": 8, "label": "Fullscreen Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjoh" } } }, { "id": 9, "label": "Fullscreen Mobile", "provider": "adfox", "adaptive": [ "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fjog" } } }, { "id": 10, "label": "Native Partner Desktop", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyb" } } }, { "id": 11, "label": "Native Partner Mobile", "provider": "adfox", "adaptive": [ "phone" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fmyc" } } }, { "id": 12, "label": "Кнопка в шапке", "provider": "adfox", "adaptive": [ "desktop", "tablet" ], "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fdhx" } } }, { "id": 13, "label": "DM InPage Video PartnerCode", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox_method": "createAdaptive", "adfox": { "ownerId": 228129, "params": { "pp": "h", "ps": "clmf", "p2": "flvn" } } }, { "id": 14, "label": "Yandex context video banner", "provider": "yandex", "yandex": { "block_id": "VI-250597-0", "render_to": "inpage_VI-250597-0-1134314964", "adfox_url": "//ads.adfox.ru/228129/getCode?pp=h&ps=clmf&p2=fpjw&puid1=&puid2=&puid3=&puid4=&puid8=&puid9=&puid10=&puid21=&puid22=&puid31=&puid32=&puid33=&fmt=1&dl={REFERER}&pr=" } }, { "id": 15, "label": "Плашка на главной", "provider": "adfox", "adaptive": [ "desktop", "tablet", "phone" ], "adfox": { "ownerId": 228129, "params": { "p1": "byudo", "p2": "ftjf" } } }, { "id": 17, "label": "Stratum Desktop", "provider": "adfox", "adaptive": [ "desktop" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvb" } } }, { "id": 18, "label": "Stratum Mobile", "provider": "adfox", "adaptive": [ "tablet", "phone" ], "auto_reload": true, "adfox": { "ownerId": 228129, "params": { "pp": "g", "ps": "clmf", "p2": "fzvc" } } } ]
Новая игра Ubisoft на релизе выглядит
точно так же, как и на E3
Подписаться на push-уведомления