Многие разработчики хотят, чтобы их продукт был доступен максимально широкому кругу пользователей. И локализация на языки целевой аудитории может достаточно положительно сказаться на её росте. Вряд ли в данной статье будет что-то новое для людей, которые собаку съели на локализации, однако постараюсь поделиться максимально полезными советами по реализации для тех, кто только начинает свой путь.
Я являюсь выпускником МИП-15 2023. В свободное от работы время делаю фэнтезийное MMORPG в телеграме — Krezar Tavern. Не модные нынче миниаппы, а классический чат‑бот, без монетизации, блокчейнов и прочего, чисто пет‑проект для души. Все исходники лежат в открытом доступе на гитхабе.
Урок 1. Форматирование строк
Дано: два персонажа имеют возможность устроить дуэль. У текста начала дуэли может быть 1 и более вариаций для каждого языка. В каждом тексте упоминаются два участника в произвольном порядке.
Давайте напишем наивную реализацию и попробуем её улучшить:
public static String initDuel(Language language, Personage initiator, Personage acceptor) {
final var templates = switch (language) {
case RU -> List.of(
initiator.badgeWithName() + " явно намеревается дать по щам " + acceptor.badgeWithName() + "!",
"Гоп стоп! " + acceptor.badgeWithName() + " стопанули за углом таверны! " + initiator.badgeWithName()
+ " - серьезная персона и не собирается церемониться на дуэли!"
);
case EN -> List.of(initiator.badgeWithName() + " starts a duel with " + acceptor.badgeWithName() + "!");
};
return RandomUtils.getRandomElement(templates);
}
Какие тут недостатки?
Лишние конкатенации строк. По факту нам нужна одна готовая строка, а не все.
Легко ошибиться при копипасте. Субъективно, но по ощущениям именно так.
Сложно читать.
Форматирование в стиле СИ-строк
"%s явно намеревается дать по щам %s!".formatted(initiator.badgeWithName(), acceptor.badgeWithName())
Выглядит уже красивее, однако лишнее создание строк никуда не делось.
Форматирование с позиционными аргументами
"{0} явно намеревается дать по щам {1}!";
....
MessageFormat.format(RandomUtils.getRandomElement(templates), initiator.badgeWithName(), acceptor.badgeWithName())
Уже лучше, мы контролируем порядок слов, не создаем строки лишний раз, но страдает читаемость. Без контекста не понять, что такое 0, а что такое 1. На этом примере не так видно, но посмотрим сюда:
final var template = """
📜${0} ${1}${2}
${3}${4} (${5}) ${6}${7} (${8})
${9}${10} (${11}) ${12}${13} (${14})
+${15}${16}""";
Страшно, правда?
Форматирование с именованными аргументами
На самом деле, всё что надо сделать в предыдущем пункте — добавить аргументам имена.
"${initiator_icon_with_name} явно намеревается дать по щам ${acceptor_icon_with_name}!"
Выглядит сразу намного понятнее, особенно для неподготовленного человека (а разработчик на следующий день после написания кода уже является неподготовленным человеком).
Скрытый текст
Отдельная заметка как с этим работать. В Java встроенного инструмента нет. Я провёл несколько экспериментов в лоб из 4 решений: через replace, Regexp, StringSubstitutor из apache, и StringBuilder. По итогу выиграло решение через builder, конечная реализация — здесь.
Финальный код для старта дуэли будет выглядеть примерно так:
final var templates = switch (language) {
case RU -> List.of(
"${initiator_icon_with_name} явно намеревается дать по щам ${acceptor_icon_with_name}!",
"""
Гоп стоп! ${acceptor_icon_with_name} стопанули за углом таверны! ${initiator_icon_with_name} \
- серьезная персона и не собирается церемониться на дуэли!"""
);
case EN -> List.of("${initiator_icon_with_name} starts a duel with ${initiator_icon_with_name}!");
};
final var params = new HashMap<String, Object>();
params.put("initiator_icon_with_name", initiator.badgeWithName());
params.put("acceptor_icon_with_name", acceptor.badgeWithName());
return StringNamedTemplate.format(
RandomUtils.getRandomElement(templates),
params
);
А бонусом, тот страшный пример с 17ью аргументами:
final var template = """
📜${personage_badge_with_name} ${health_icon}${remain_health}
${normal_attack_icon}${normal_damage_value} (${normal_damage_count}) ${crit_attack_icon}${crit_damage_value} (${crit_damage_count})
${damage_blocked_icon}${damage_blocked_value} (${damage_blocked_count}) ${dodge_icon}${dodged_damage_value} (${dodged_damage_count})
+${reward_value}${money_icon}""";
Согласитесь, что стало понятнее, о чём здесь речь.
Урок 2. Частичная локализация
Совет актуален для тех разработчиков, кто в силах поддерживать один‑два перевода, а остальные делает сообщество. Очевидно, что сообщество будет отставать на некоторое время от выкатки фич. Многие используют следующую схему в таких случаях, и вряд ли есть что‑то лучше:
Есть локализация по‑умолчанию, которая покрывает 100%
Если по какой‑то локализации не хватает перевода, тогда берётся из дефолта.
Проще всего реализовать с помощью Map:
private static final Map<Language, List<String>> initDuelMap = new HashMap<>() {{
put(Language.RU, List.of(....));
put(Language.EN, List.of(....));
}};
public static String initDuel(Language language, Personage initiator, Personage acceptor) {
var templates = initDuelMap.get(language);
if (templates == null) {
templates = initDuelMap.get(Language.DEFAULT);
}
final var params = new HashMap<String, Object>();
params.put("initiator_icon_with_name", initiator.badgeWithName());
params.put("acceptor_icon_with_name", acceptor.badgeWithName());
return StringNamedTemplate.format(
RandomUtils.getRandomElement(templates),
params
);
}
Урок 3. Файлы Локализации
В примерах выше локализация лежит прямо рядом с кодом. Казалось бы, что в этом плохого?
Иногда локализацией занимаются отдельные люди, которые не разбираются в программировании, для них модифицировать исходники — сложно.
Много лишнего в исходниках — когда просматриваешь код, локализация будет просто отвлекать внимание от основной логики.
Сложно вносить изменения — даже если разработчик один, будет тратится лишнее время, чтобы найти нужный файл и конкретное место для правок.
Очевидное решение — вынести локализацию в отдельные файлы. Я выбрал формат TOML для этих целей.
Скрытый текст
Небольшая табличка‑сравнение полуторагодовой давности, с разными форматами
Всем критериям удовлетворяли yaml и toml, но yaml мне категорически не нравится. И за время использования toml стал моим любимым языком конфигурации.
Итоговое решение, к которому я пришёл:
Файлы локализации разбиты на домены по языкам. Лучше много маленьких файлов, чем мало больших.
├── en
│ ├── duel.toml
└── ru
├── duel.toml
В момент старта приложения файлы парсятся в классы ресурсов и сохраняются в Map вида <Language, Resource>
ResourceUtils.doAction(
LOCALIZATION_PATH + language.value() + DUEL_PATH,
// DuelLocalization содержит внутри статический параметр для сохранения ресурсов
it -> DuelLocalization.add(language, extractClass(mapper, it, DuelResource.class))
);
Дальше в рантайме достаём нужную локализацию из Map
public static String initDuel(Language language, PersonageMention initiatorMention, PersonageMention acceptorMention) {
final var params = new HashMap<String, Object>();
params.put("mention_initiator_icon_with_name", initiatorMention.value());
params.put("mention_acceptor_icon_with_name", acceptorMention.value());
return StringNamedTemplate.format(
/*
resources - Это обертка над Map, в которой скрыт дублирующийся код
по работе с массивами и объектами по умолчанию
*/
resources.getOrDefaultRandom(language, DuelResource::initDuel),
params
);
}
Подход не идеальный: храним локализацию в памяти, куча статик методов, но с такой структуры точно можно начать и модифицировать далее под свои нужды.
Урок 4. Словоформы
Во многих языках у одного слова может быть множество различных форм. В русском языке это в основном выражено падежами и родами. Рассмотрим на примере:
“This ${item} will be worth ${value} ${currency}”.
На английском нет проблем с подстановкой слов, а вот как может выглядеть итоговая строка на русском:
«Этот копьё будет стоить 10 золото» или «Это палица стоит 5 золото».
Как исправить?
Используем иконки вместо слов где можем, например «💰» вместо «золото».
Оптимизируем предложения, чтобы наши подлежащие были независимы от окружения.
«Копьё будет стоить 10💰» и «Spear will be worth 10💰».
Если вдруг, всё же возникает потребность в разных преобразованиях для языков, то вполне возможно что придётся писать специфичный код для каждой локализации.
Вот так в моём проекте выглядит работа с формами слов:
private static String itemWithPrefixAndSuffixModifier(Language language, Item item, Modifier prefix, Modifier suffix) {
final var params = new HashMap<String, Object>();
final var objectLocale = item.object().getLocaleOrDefault(language);
params.put("object", objectLocale.text());
// Считаем, что у Modifier есть либо нужная форма либо форма WITHOUT, которая подходит всем
params.put("prefix_modifier", prefix.getLocaleOrDefault(language).getFormOrWithout(objectLocale.form()));
params.put("suffix_modifier", suffix.getLocaleOrDefault(language).getFormOrWithout(objectLocale.form()));
return StringNamedTemplate.format(
resources.getOrDefault(language, ItemResource::itemWithPrefixAndSuffixModifier),
params
);
}
Урок 5. Словарь
Если у вас есть большой проект, который изобилует большим количеством терминов, названий и так далее, задумайтесь о заведении словаря. Его не нужно использовать непосредственно для отображения игроку. Но с его помощью переводчик сможет сделать намного более согласованный текст и у ваших игроков будет более полный контекст.
Например, в моём проекте персонажи являются Искателями. В онлайн переводчиках предлагаются следующие варианты: Finder, Searcher, Seeker, Looker. Или это может касаться географических названий, когда не всегда понятны принципы транслитерации или адаптации, как легендарный Stormwind/Штормград. Поэтому важно фиксировать подобные моменты в словаре.
Заключение
Я надеюсь, что перечисленные выше советы помогут начинающим локализаторам избежать ряда граблей. На самом деле эта сфера намного глубже, существует специальное ПО для этих целей и даже целые студии. Однако, нужны такие сложные (а иногда и дорогие) вещи далеко не всем. Можно начать с использования перечисленного выше, а дальше дорабатывать решение под себя. Если я упустил что‑то важное из виду, дополните меня в комментариях.