Files

25 KiB
Raw Permalink Blame History

Локализация и моды — проект

Цель: переводить и модифицировать игры прямо в библиотеке, нативно и удобно. Переводы из самих игр импортируются при конвертации, дальше живут только в нашем формате.

Почему это нужно решить сейчас

Исходники игры удаляются после конвертации. Всё, что не вытащено в момент конвертации (готовые переводы из tl/, привязка реплик к их месту в исходнике), потом взять будет неоткуда. Поэтому стабильные идентификаторы и импорт переводов должны появиться в конвертере до того, как библиотека наполнится играми.

Что есть в играх (по примерам)

Игра Языки в tl/ Переведённых реплик
That New Teacher 19 (в т.ч. russian) ~350 000 блоков
Twisted Thoughts 9; исходник на французском, english — перевод ~117 000
остальные 4 только исходный —

Формат Ren'Py:

# game/Days/dayEight.rpy:248
translate russian D8_Clarissa_c7855708:
    # "You lie on your side, facing away..."
    "Ты лежишь на боку, отвернувшись..."

translate russian strings:
    old "Iris"
    new "Айрис"

Реплики переводятся блоками с идентификатором (метка_хэш) и ссылкой на строку оригинала; имена, пункты меню и строки интерфейса — таблицей old/new. Бывает и игровая логика, зависящая от языка (_preferences.language).

Главная идея: текст отдельно от скрипта, у всего стабильные ID

  1. ID узлов. Каждый оператор скрипта получает постоянный id. Он присваивается один раз при конвертации и дальше не меняется при правках. На этих ID держатся переводы, сохранения и в будущем слияние модов.

  2. ID строк. Реплики получают tid в стиле Ren'Py (метка_хэш) и хранят его в скрипте, так что правки текста ID не меняют. Пункты меню, имена, _() и show text переводятся таблицей строк по исходному тексту, как в Ren'Py.

  3. Исходный текст остаётся в скрипте. Скрипт читается как есть, язык оригинала — это просто «исходная локаль» (у Twisted Thoughts — французский).

  4. Переводы — отдельные таблицы locales/<язык>/<модуль>.json:

    { "start_577f33b3": { "t": "Ммм...", "src": "a1b2c3", "state": "translated" } }
    

    src — хэш исходного текста на момент перевода. Если в моде или новой версии оригинал реплики изменился, перевод помечается «устарел», а не теряется молча. Состояния: translated, fuzzy/устарел, machine, approved.

  5. Файлы локали режутся по модулям скрипта. Правка одной реплики создаёт один новый маленький блоб — остальное общее (наш content-addressed store).

locales/<язык>/meta.json: код языка (BCP-47: ru), название, авторы, прогресс, шрифт-замена (у многих игр в шрифтах нет кириллицы или CJK), переопределения ассетов для языка: картинки с надписями, озвучка.

Импорт переводов из игры

При конвертации разбираем tl/<lang>/:

  • блок translate <lang> <id> сопоставляем с нашей репликой по комментарию # game/file.rpy:строка (точно и просто), запасной вариант — по ID Ren'Py;
  • translate <lang> strings превращаем в таблицу old→new для имён, меню и _();
  • языки Ren'Py (russian, spanish_latam, None) переводим в коды ru, es-419 и исходную локаль;
  • импортируются все языки игры — это только текст, места почти не занимает.

Плеер

  • Выбор языка в главном меню и настройках, запоминается для каждого пользователя.
  • Цепочка запасных языков: ru → en → исходный. Непереведённая строка показывается на запасном языке и не ломает игру.
  • _(), флаг !t в [переменных], имена персонажей переводятся из таблиц; _preferences.language отдаёт выбранный язык, поэтому языковая логика игры (как в Twisted Thoughts) работает.
  • Шрифт и ассеты подменяются из meta.json локали.

Версии и публикация

Сценарий:

  1. Модератор делает форк главной версии: «Перевод RU (черновик)».
  2. Переводит в редакторе. Форк виден только ему и тем, у кого есть права на рецензию.
  3. Нажимает «Опубликовать перевод».

Публикация переносит локаль, а не подменяет версию. Если два переводчика параллельно делают RU и DE в двух форках, «сделать форк главным» потеряет работу одного из них. Поэтому публикация создаёт новую главную версию: главная + локаль из форка. Переводы склеиваются по tid построчно, это тривиально. Перенос перевода в любую совместимую версию (например, в мод) работает так же — одной кнопкой, по тем же ID.

Модели:

  • games.main_version_id — главная версия (её открывает кнопка «Играть»);
  • у версии visibility: draft / published, а также kind: оригинал, перевод, мод;
  • новые права: translations.edit — переводить в своих форках, versions.publish — публиковать, назначать главную.

Редактор перевода

  • Таблица строк по сценам и меткам: оригинал | перевод | статус, фильтры «не переведено / устарело / машинное», поиск, прогресс в процентах.
  • Контекст строки: кто говорит, соседние реплики, фон и спрайты сцены на этом месте, кнопка «проиграть с этой строки» в плеере.
  • Глоссарий: имена и термины подставляются и проверяются на единообразие.
  • Экспорт и импорт PO/XLIFF/CSV для внешних инструментов, экспорт обратно в формат Ren'Py tl/.
  • Позже: режим «править прямо в игре» — клик по реплике в плеере; машинный предперевод с пометкой machine.

Моды

Те же основы делают моды нативными:

  • скрипт редактируется на уровне меток и операторов с постоянными ID;
  • мод — форк с изменёнными модулями скрипта и ассетами: в хранилище только отличия;
  • переводы оригинала автоматически применяются к неизменённым репликам мода, новые реплики мода видны как непереведённые;
  • так как у всего есть ID и родитель, можно делать трёхстороннее слияние: мод A + мод B, или перенос мода на новую версию оригинала.

Принятые решения

  • Публикация перевода — слияние локали в новую главную версию, а не подмена главной версии форком.
  • Из игр импортируются все языки tl/.
  • Машинный предперевод — на стороне клиента: переводит программа local-translator на компьютере переводчика, сервер библиотеки о ней не знает (см. ниже).
  • Начинаем с этапа 1.

Состояние

Этап 1 сделан:

  • у всех узлов скрипта есть id, у реплик — tid;
  • при конвертации импортируются все языки из tl/. Блок сопоставляется с репликой по комментарию # game/file.rpy:N, а если он устарел — по метке и исходному тексту. Отдельные таблицы strings тоже импортируются;
  • исходный язык берётся из config.language или определяется по тексту (у Twisted Thoughts — французский);
  • в манифесте лежат locales/<код>/<модуль>.json и locales/<код>/strings.json, в game.json — sourceLang и locales с прогрессом;
  • в плеере есть выбор языка в главном меню и настройках, запасной язык — исходный, работают __(), !t, _preferences.language.

Проверено на примерах: Twisted Thoughts — 9 языков, по 12 949 реплик, 100% сопоставлено. That New Teacher — 18 языков, около 75% реплик: остальное авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым английским репликам. Ren'Py их тоже не показывает.

Этапы 2 и 3 сделаны:

  • у игры есть главная версия (games.main_version_id), у версий — visibility (draft/published) и kind (original/translation/mod); миграция 0002 проставила главную версию существующим играм;
  • права translations.edit и versions.publish, у системной роли moderator они включены;
  • кнопка «Перевести» создаёт черновик-форк вида «перевод», «Опубликовать» вливает изменённые языки в саму главную версию: её ID не меняется, сохранения игроков остаются. Прежний манифест пишется в version_history (GET /api/versions/{id}/history, откат — POST …/history/{hid}/restore);
  • редактор перевода /versions/{id}/translate:
    • языки с прогрессом по состояниям, реплики и строки (имена, меню, текст на экране);
    • фильтры «к работе / не переведено / устарело / неточно / машинный / переведено / одобрено», поиск, фильтр по файлу скрипта;
    • автосохранение, Ctrl+Enter — к следующей строке;
    • кнопки «одобрить» (только у публикующих), «актуально» для устаревших, «неточно», «скопировать оригинал»;
    • контекст строки: фон сцены, соседние реплики, «играть отсюда» (/play/{id}?at=<узел>&lang=<язык>, картинка сцены восстанавливается);
    • глоссарий с подсветкой терминов в оригинале;
    • экспорт и импорт PO/CSV, шрифт для языка;
  • API: GET/PUT /api/versions/{id}/i18n[/{lang}], …/entries, …/meta, …/font, …/export, …/import, DELETE …/{lang}.

Пока не сделано: UI истории версии (сейчас есть только API), картинки и озвучка для отдельного языка, правка реплик прямо в плеере.

Машинный перевод через локальный переводчик

Страница /translator («Локальный переводчик» в меню профиля) объясняет, как поставить LM Studio и local-translator, какую модель взять по объёму видеопамяти, и проверяет связь прямо из браузера. В редакторе перевода кнопка «Машинный перевод» открывает панель: источник и модель, строк за запрос, пожелания к стилю (запоминаются для игры), «имена и интерфейс», «обновить устаревшие».

Как это работает:

  • браузер сам ходит на http://127.0.0.1:8787 (адрес можно сменить на странице настройки); библиотека получает только обычные PUT …/i18n/{lang} с состоянием machine, ни адреса, ни моделей она не видит;
  • для каждой игры и пары языков в переводчике создаётся проект PGames: <игра> (<исходный> → <язык>): контекст из описания игры, глоссарий из вкладки «Глоссарий», пожелания пользователя. Проект находится по имени и обновляется перед каждым запуском;
  • запуск живёт в провайдере выше страниц (translator/runner.tsx): перевод идёт, пока вкладка открыта, даже если уйти из редактора; его карточка с прогрессом и кнопкой «Остановить» стоит в том же плавающем окне, что и загрузки. Одновременно работает только один перевод (на все игры и языки; между вкладками это держит Web Lock), пока он идёт, кнопки отдельных строк недоступны; закрытие вкладки браузер подтверждает. На каждой странице запуск записывается в localStorage (pg.translation.run: что переводится, где остановились, какое задание отправлено в переводчик). После обновления страницы сайт сам продолжает: забирает готовое задание из переводчика, сохраняет его и идёт дальше, а карточка и бейджи снова обновляются. После ошибки или остановки карточка и панель редактора предлагают «Возобновить»: сохранённые строки остаются на месте, непереведённые (включая прошлые ошибки) повторяются. Запись старше 30 минут тоже доступна для возобновления, но сама не запускается. Состояние удаляется после успешного завершения или кнопкой «Убрать»;
  • автоперевод идёт по списку так, как его видит пользователь: текущая вкладка («Реплики» или «Имена и меню»), фильтр по файлу и поиск, сверху вниз. Берутся строки «не переведено» (и «устарело» по желанию) страницами по 40; страница уходит одним заданием, готовое сразу сохраняется, появляется в открытых строках и обновляет прогресс в шапке;
  • список разбит на страницы (20/40/100/200 строк, номера страниц, переход по номеру); сервер в GET …/entries по параметру locate=<ключ> возвращает located, позицию строки в том же отфильтрованном списке. По нему режим «Следить за переводом» (включён по умолчанию, выключается кнопкой над списком, флажком в панели или ручным листанием) открывает страницу, на которой модель работает сейчас, и подсвечивает строки текущей пачки;
  • у каждой строки есть кнопка «Перевести» (пока переводчик подключён): она переводит одну строку с тем же контекстом и глоссарием и помечает её «Машинный». Редактор ищет переводчик сам, только если он уже работал в этом браузере, чтобы Chrome не спрашивал про локальную сеть без причины;
  • вкладка «Имена и меню» собирает не только имена, пункты меню и show text "…", но и строки _("…") из скриптов и постоянный текст экранов (text, textbutton, label): у них нет идентификатора реплики, поэтому они переводятся по тексту оригинала, как в Ren'Py. Список строится из уже конвертированной версии, повторный импорт игры не нужен;
  • декоративные теги Ren'Py ({size=…}, {i}, {b}, {color=…} и т. п.) внутри слов (d{size=38}e{/size}ep) после перевода на место не вернуть, поэтому такие строки уходят модели без этих тегов, а результат помечается «Неточно». Строки с тегами в других местах сначала переводятся как есть, а если переводчик отверг ответ (Protected tokens were changed…), переводятся ещё раз без декоративных тегов и тоже получают «Неточно». Служебные теги ({w}, {nw}, {p}), [переменные] и прочее остаются под защитой, такая строка при сбое просто не переводится;
  • флажок «Передавать описание и жанры игры как контекст» (по умолчанию включён) добавляет описание и теги игры в контекст проекта; название игры уходит всегда; Остановка отменяет задание в переводчике; повторный запуск продолжает с оставшегося. Непереведённые из-за ошибки строки (например, модель повредила плейсхолдер) пропускаются и видны в счётчике ошибок;
  • переводчик по умолчанию закрыт для браузера. Адрес сайта вписывается в allowed_origins его config.toml (точный адрес, без *); для Chrome сервер отвечает на заголовок Private Network Access, а сам браузер один раз спросит разрешение на доступ к локальной сети. Страница настройки отличает «не запущен» от «запущен, но сайт не разрешён»;
  • код: frontend/src/translator/ (client.ts — API переводчика, run.ts — цикл перевода, MachineTranslate.tsx — панель, TranslatorSetup.tsx — страница настройки); проверки в frontend/tests/translator-run.test.mjs.

Сообщение Model output was truncated or filtered приходит от local-translator, когда LM Studio возвращает finish_reason=length (исчерпан лимит ответа) либо content_filter. По одному этому тексту отличить причины нельзя: для диагностики нужны метрики вызова переводчика. Пачка ограничивается отдельно по числу строк и по символам; лимит выходных токенов задаётся в конфигурации провайдера local-translator. Повтор после такой ошибки использует вдвое меньшую пачку (минимум одну строку). Успешный повтор убирает устаревшее сообщение ошибки модели. Сбой получения строк или сохранения в библиотеку показывается отдельно с этапом и HTTP-кодом, если сервер не вернул текст ошибки. Готовые результаты задания и ключи уже сохранённых строк остаются в контрольной точке: возобновление не отправляет готовое задание заново и не перезаписывает эти строки.

Ограничения: перевод идёт, пока открыта страница редактора; контекст (кто говорит, соседние реплики) модели пока не передаётся, только текст строки; Safari может запрещать https-странице обращаться к локальному http.

Уже сконвертированные версии остаются без ID и без переводов, их нужно переимпортировать. Пока в библиотеке только тестовые данные, отдельную миграцию не делаем.

Этапы

  1. Фундамент (сейчас, до наполнения библиотеки): ID узлов и строк в конвертере, импорт tl/ всех языков, локали в манифесте, выбор языка и запасные языки в плеере, _()/!t/_preferences.language. Миграция уже сконвертированных версий: ID детерминированно выводятся из текущего IR.
  2. Перевод в библиотеке: главная версия, черновик и публикация, права, редактор перевода, прогресс, экспорт и импорт PO/CSV.
  3. Удобство: контекст сцены, «проиграть отсюда», глоссарий, рецензирование, шрифты и ассеты локалей, машинный предперевод.
  4. Моды: редактор скриптов, замена ассетов, слияние и перенос модов.