Кратко о локализации в s&box
Недавно я добавил русскую локализацию в Terry Dev Tycoon, и среди нововведений в Update 1 она оказалась самой времязатратной. Меня это несколько озадачило, так что в преддверии полноценной статьи по этой игре я решил оставить небольшую заметку о том, как в s&box происходит локализация.

Перевод игры
В документации s&box есть отдельная страница, посвящённая этому процессу. И в первом приближении она действительно повествует тебе самое важное:
- Ресурсы хранятся в
YourProject/Localization/LangCode/file.json. - Формат ресурсов — ключ-значение
"key": "value". - Чтобы использовать локализированную строку в UI, достаточно подставить
#перед ключом. Например,<p>#your.resources.key</p>.
Какие здесь есть подводные камни? Давайте по порядку.
Динамические строки
Едва ли любой случай будет исчерпываться тем, что указан выше. У меня в игре есть экран с созданием игры, где у каждого фокуса отображается его текущий процент.
Упрощённо код без локализации можно представить вот так:
<span class="h3">Engine: @CalculatePart(Prototype.Engine)%</span>
Казалось бы, достаточно заменить Engine на #ui.aspect.engine и всё? Как бы не так! На текущий момент движок локализации ищет полное совпадение, т. е. он посчитает за часть ключа и текст, следующий за ключом: : 23%, если брать за пример скриншот.
Здесь на помощь приходит статический класс Language. Его метод GetPhrase позволяет напрямую получить нужную строку по ключу, минуя движок, который используется для .razor файлов. Если позвать Language.GetPhrase("ui.aspect.engine") — то мы получим Engine в английском и Движок в русском. Обратите внимание — # использовать здесь не нужно.
Итого строка может выглядеть так:
<span class="h3">@Language.GetPhrase("ui.aspect.engine"): @CalculatePart(Prototype.Engine)%</span>
Это был более сложный случай, где строка формируется динамически. Но могут быть и простые, например, когда после строки идёт какая-либо пунктуация вроде : или надо просто соединить несколько таких строк воедино. Благодаря такому подходу можно не тащить всё в файлы локализации и сконцентрироваться исключительно на тексте.
Издержки поздней локализации
В общем, это пока самый рабочий способ локализации динамических строк, что я нашёл. Местами он не очень элегантный, так как до этого я уже успел завязаться на строки. Например, тот же пример выше у меня в коде выглядит так:
<span class="h3 @focusClasses[eng]">@Language.GetPhrase(eng.Substring(1)): @CalculatePart(Prototype.Engine)%</span>
Так вышло из-за того, что одни и те же константы использовались как для стилей и легенды в компоненте круговой диаграммы, так и для подписей процентов на странице создания игры. Из-за этого прямо в разметке приходится удалять # из начала строки.
Локализация ассетов
Помимо текста, который прописывается прямо в UI, у вас также может быть текст, который подгружается из ассетов. Либо вам может понадобиться менять логику их загрузки в зависимости от языка. В Terry Dev Tycoon отдельно загружаются и локализуются:
- обзоры на игры;
- описание задач на трекере;
- тексты игровых событий.
Они все хранятся в TOML-файлах. Само по себе это проблемой не является, так как всё ещё можно использовать те же ключи, которые s&box подхватит. Но с игровыми событиями пришлось выкручиваться.
В прошлой статье про разработку этого сайта я освещал, почему для игры выбрал TOML, и одной из главных причин была поддержка многострочного текста. Жертвовать ею ради локализации в JSON-файле очень не хотелось, так что единственным выходом было делать отдельные файлы на каждую локализацию и подгружать их динамически.
Здесь снова на помощь приходит Language, у которого можно получить текущий язык — свойство Current. В своём коде загрузки событий я добавил определение текущего языка:
var lang = Language.Current.Abbreviation;
if (lang == "ru" && _byLanguage.TryGetValue("ru", out var ruList))
return ruList.Find(x => x.Id == id);
else
return _byLanguage["en"].Find(x => x.Id == id);
Специальная обработка имеется только для поддерживаемого языка. И если язык не поддерживается или что-то было переведено не до конца, то подставится английский вариант.
Тут отдельно отмечу одну западню: я бы не смог так просто это сделать, если бы мне нужно было делать «лог» событий, где ты бы мог их перечитать в любой момент. В файле сохранений (да и просто в памяти) я бы хранил уже локализованную строку, которая при изменении настроек сама на нужный язык не переведётся, пока ты не сделаешь для этого велосипед. Именно поэтому, конечно, лучше использовать решение из коробки по максимуму.
Как упростить себе жизнь, если перевод планируется в будущем
Я с самого начала знал, что добавлю в игру локализацию. Однако её приоритет был низким, а сама разработка затягивалась всё сильнее и сильнее. В конце концов я даже вычеркнул её из списка TODO к релизу, оставив на потом. Но немного уменьшить работу в будущем у меня всё же получилось: весь текст я старался заранее писать в константы. Например:
<div class="entry active" onclick="@OpenGameCreation">
<i class="head">sports_esports</i>
<div class="body">@create</div>
</div>
@code {
private const string create = "Make a new game";
}
Благодаря этому процесс локализации прошёл заметно более гладко, так как не нужно было выискивать в разметке глазами текст — он весь был в константах. Достаточно было заменить текст в них:
@code {
private const string create = "#ui.top.create";
}
И всё. Да, приятнее делать разметку, видя, что в ней будет, но покуда всё равно придётся использовать нечитабельные ключи разметки, то можно сразу перейти на использование именованных констант. Не то чтобы это сработало идеально — о проблеме с динамическими строками я писал выше, да и какие-то единицы текста случалось пропустить, так что после локализации констант пришлось несколько раз перепроверять весь UI внутри игры. Но в целом подход мне показался верным.
Подводя итоги
Да, по большому счёту, для локализации надо знать всего три вещи:
- локализация в
.razorчерез#; - динамическая подгрузка через
Language.GetPhrase("key"); - произвольная логика в зависимости от текущей локали в
Language.Current.
Но мне за каждой из них надо было лезть либо в Discord, либо в API Reference, так что, думаю, этот опыт может оказаться полезным.