Files
minecraft-launcher/doc/cpp/Localization.md
T
2026-09-03 09:16:56 +03:00

11 KiB
Raw Blame History

Localization

localization.h / localization.cpp

Единственный источник всех текстов интерфейса. Каталог — i18n/translations.json, он лежит в ресурсах и правится руками. Штатных .ts/.qm в проекте нет намеренно: lupdate, lrelease и Linguist не нужны, а все переводы видны в одном файле.

Язык переключается на лету: при смене все привязки QML перевычисляются, перезапуск не требуется.

Доступ из QML

Синглтон зарегистрирован декларативно (QML_NAMED_ELEMENT(Loc) + QML_SINGLETON) и доступен в любом файле модуля без импорта.

Text { text: Loc.t.settings.title }
Text { text: Loc.t.java.progress.downloading.arg(label) }
DarkCombo { model: Loc.t.profile.authTypes }

Точечный путь Loc.t.a.b.c — это обращение к вложенным объектам дерева, которое строится из плоских ключей каталога разбиением по точке.

Почему свойство, а не метод. Вызов Q_INVOKABLE не регистрирует зависимость привязки, и text: Loc.t("ключ") никогда бы не обновился при смене языка. Чтение Q_PROPERTY с сигналом NOTIFY зависимость регистрирует: languageChanged перевычисляет все привязки, которые читали Loc.t. Сегменты после t — обычные обращения к членам JS-объекта, отслеживать их не нужно, потому что при смене языка дерево заменяется целиком.

Тип свойства — QJSValue, а не QVariantMap: QVariantMap пересобирался бы в новый JS-объект при каждом чтении, а привязок в проекте полторы сотни. QJSValue строится один раз на смену языка.

Из тела JS-функции Loc.t читается так же — это просто чтение свойства:

onLaunched: (profileName, buildName) =>
    window.showToast(Loc.t.launch.status.started.arg(profileName).arg(buildName), "#4b7a1f")

Доступ из C++

Свободные функции, а не методы: их вызывают и из namespace-обёрток (LauncherPaths, JavaLocator, ZlibReference), где никакого QObject нет.

#include "localization.h"

emit launchError(Loc::text("launch.error.noProfile"));
emit launchProgress(Loc::text("java.progress.downloading").arg(label));
const QStringList kinds = Loc::list("profile.authTypes");

Ключа нет — возвращается сам ключ, а в отладочной сборке ещё и qWarning: строка вида launch.error.noProfile в интерфейсе сразу бросается в глаза.

Потоки

Loc::text() и Loc::list() можно звать из любого потока. Каталог заполняется ровно один раз в load(), который отрабатывает в main() до того, как BuildSwitcher создаст свой поток; дальше он только читается, а копирование QString из хэша безопасно само по себе. Единственное, что меняется на ходу, — индекс текущего языка, и он QAtomicInt. В худшем случае сообщение, которое собиралось в момент переключения, уедет на прежнем языке.

setLanguage() и рассылка languageChanged — только поток GUI; это проверяется Q_ASSERT.

Каталог

i18n/translations.json попадает в ресурсы через список RESOURCES в qt_add_qml_module, поэтому читается по пути :/qt/qml/Minecraft_launcher/i18n/translations.json.

{
  "_meta": {
    "languages": ["ru", "en"],
    "displayNames": { "ru": "Русский", "en": "English" }
  },
  "strings": {
    "settings.title": { "ru": "Настройки запуска", "en": "Launch settings" },
    "java.progress.downloading": { "ru": "Загрузка Java «%1»…", "en": "Downloading Java \"%1\"…" },
    "profile.authTypes": {
      "ru": ["Офлайн (без пароля)", "Ely.by", "Microsoft (лицензия)"],
      "en": ["Offline (no password)", "Ely.by", "Microsoft (licensed)"]
    }
  }
}

Ключи плоские, языки рядом: забытый перевод виден на соседней строке, правка пары — один участок файла, а третий язык добавляется колонкой без правок загрузчика.

Имя ключа — <домен>.<вид>.<имя>, сегменты в lowerCamelCase.

  • Домен — область, а не имя файла: app, common, settings, profile, build, seasonal, version, java, loader, auth (с auth.ely.*, auth.msa.*), launch, game, switch, storage, zlib.
  • Видtitle, label, button, placeholder, hint, header; для сообщений error, progress, status, warning.
  • Имя описывает условие, а не формулировку, чтобы перевод не переименовывал ключ: gameRunning, downloadFailed, checksumMismatch.

Литерал, который нужен в двух и более файлах, живёт в common.*.

Подстановки %1/%2 сохраняются дословно: их одинаково понимают QString::arg() и QML-овский String.arg(). Набор %N в ru и en обязан совпадать — порядок слов может отличаться, состав нет. Множественного числа формат не поддерживает; в коде сейчас нет ни одной строки, которой оно требуется.

Как добавить строку

  1. Добавить запись в strings файла i18n/translations.json — сразу с ru и en.
  2. Сослаться на неё: Loc.t.домен.вид.имя в QML или Loc::text("домен.вид.имя") в C++.
  3. Прогнать python3 tools/check_translations.py.

Как добавить язык

  1. Дописать код в _meta.languages и название в _meta.displayNames.
  2. Добавить колонку с этим кодом в каждую запись strings.
  3. Добавить код в stLanguage.codes и пункт в модель комбобокса в Main.qml, а также ключ settings.language.<код> с эндонимом (название языка не переводится — оно одинаково во всех колонках).
  4. При необходимости поправить systemLanguage() в localization.cpp: сейчас он выбирает русский для русской системной локали и английский во всех остальных случаях.

Выбор языка и его хранение

Ключ настройки — language, значения "system", "ru", "en", по умолчанию "system". Хранится в settings.json рядом с остальными настройками; в интерфейсе — первым пунктом диалога «Настройки запуска», применяется по кнопке «Сохранить».

Круг замкнут в одну сторону, циклической зависимости нет:

main.cpp ──► Localization::load() ──► LauncherPaths::settingsFile()   (только чтение, один раз)
LauncherBackend::updateSettings() ──► Localization::setLanguage()      (в одну сторону)

Localization ничего не знает про LauncherBackend — писать settings.json по-прежнему может только он. Читать настройки самому приходится потому, что язык нужен раньше, чем QML вычислит первую привязку, а бэкенд появляется только вместе с движком.

Неизвестное значение (файл правили руками) откатывается на "system" с предупреждением. Отсутствующий или испорченный каталог — ошибка на старте: main() пишет причину и возвращает -1. Файл вкомпилирован в бинарник, так что это может быть только ошибка сборки, а лаунчер, у которого все подписи выглядят как точечные ключи, хуже, чем лаунчер, который сказал, почему не запустился.

Известное ограничение. Уже сложенные в поля C++ строки не перепереводятся: LauncherBackend::m_storageIssues собирается при старте, а stage()/status() у BuildSwitcher заменяются на следующем тике прогресса. Все они диагностические и короткоживущие.

Проверка

tools/check_translations.py — только чтение, ненулевой код возврата при любой ошибке:

  • каждый Loc::text("…") и Loc::list("…") из C++ есть в каталоге и совпадает по типу значения;
  • каждый путь Loc.t.a.b.c из QML разворачивается в существующий ключ;
  • наборы ключей у всех языков совпадают, пустых значений нет, типы одинаковы;
  • наборы %N совпадают по языкам, длины списков равны;
  • не осталось ни одного tr(, qsTr( или QCoreApplication::translate;
  • ключи, на которые никто не ссылается, — предупреждением.