Как стать автором
Обновить
127.47
Высшая школа бизнеса НИУ ВШЭ
Бизнес образование мирового уровня

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

Время на прочтение7 мин
Количество просмотров2.6K

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

Я являюсь выпускником МИП-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. Частичная локализация

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

  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 для этих целей.

Скрытый текст

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

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

Заключение

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

Теги:
Хабы:
Всего голосов 14: ↑12 и ↓2+15
Комментарии4

Публикации

Информация

Сайт
games.hse.ru
Дата регистрации
Дата основания
Численность
31–50 человек
Местоположение
Россия
Представитель
Вячеслав Уточкин

Истории