5 уроков локализации из разработки игры в Telegram

от автора

Многие разработчики хотят, чтобы их продукт был доступен максимально широкому кругу пользователей. И локализация на языки целевой аудитории может достаточно положительно сказаться на её росте. Вряд ли в данной статье будет что-то новое для людей, которые собаку съели на локализации, однако постараюсь поделиться максимально полезными советами по реализации для тех, кто только начинает свой путь.

Я являюсь выпускником МИП-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

Отдельная заметка как с этим работать. В 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. Частичная локализация

Совет актуален для тех разработчиков, кто в силах поддерживать один‑два перевода, а остальные делает сообщество. Очевидно, что сообщество будет отставать на некоторое время от выкатки фич. Многие используют следующую схему в таких случаях, и вряд ли есть что‑то лучше:

  1. Есть локализация по‑умолчанию, которая покрывает 100%

  2. Если по какой‑то локализации не хватает перевода, тогда берётся из дефолта.

Проще всего реализовать с помощью 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 для этих целей.

Почему 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/Штормград. Поэтому важно фиксировать подобные моменты в словаре.

Заключение

Я надеюсь, что перечисленные выше советы помогут начинающим локализаторам избежать ряда граблей. На самом деле эта сфера намного глубже, существует специальное ПО для этих целей и даже целые студии. Однако, нужны такие сложные (а иногда и дорогие) вещи далеко не всем. Можно начать с использования перечисленного выше, а дальше дорабатывать решение под себя. Если я упустил что‑то важное из виду, дополните меня в комментариях.


ссылка на оригинал статьи https://habr.com/ru/articles/832950/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *