Localization stage 1: stable IDs, import of tl/ translations, language picker

- every script node gets a permanent id, say nodes a Ren'Py-style tid
- all languages from the game's tl/ are imported at conversion: dialogue blocks
  matched by their source-location comment or by label + original text,
  `translate strings` become a per-language strings table
- source language from config.language or detected from the dialogue
- manifest stores locales/<lang>/<module>.json and strings.json; game.json
  lists sourceLang and available locales with progress
- player: language picker in main menu and settings, fallback to source,
  __(), [var!t] and _preferences.language
- parser: `default` with init priority, and one bad statement no longer drops
  the whole file
- docs/LOCALIZATION.md: design for translations, publishing and mods

Line endings of several converter/player files go back to LF: an earlier
commit accidentally stored them as CRLF.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:39:40 +03:00
1 parent 5d9ddb6376
commit e1d7b29f77
16 files changed
+772 -22

No files matched your search

+12 -1
View File
@@ -14,7 +14,7 @@ import time
from collections import Counter
from pathlib import Path
from app.converter import assets
from app.converter import assets, locales
from app.converter.archive import ArchiveError, find_game_dir
from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors
from app.converter.rpa import RpaError, extract_rpa, read_index
@@ -80,6 +80,13 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
except RecursionError:
data.warn("Слишком глубокая вложенность", rel)
by_line = locales.assign_ids(data.modules)
translations = locales.import_tl(work, data.modules, by_line, data.warn)
says = len(by_line) or 1
language = data.config.get("language")
source_lang = (locales.lang_code(language) if isinstance(language, str)
else locales.detect_language(data.modules))
# media aliases as the real pipeline would produce them
aliases = {k: str(Path(v).with_suffix(assets.target_ext(Path(v))).as_posix())
for k, v in files.items() if assets.kind_of(Path(v))}
@@ -104,6 +111,10 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
"stats": dict(data.stats),
"media": dict(media),
"images": len(data.images),
"sourceLang": source_lang,
"locales": {code: {"lines": len(t.lines), "strings": len(t.strings),
"progress": round(len(t.lines) / says, 3), "unmatched": t.unmatched}
for code, t in translations.items()},
"characters": len(data.characters),
"missingJumpTargets": sorted(t for t in targets if t not in labels)[:15],
"missingFiles": sorted(missing)[:15],
+280
View File
@@ -0,0 +1,280 @@
"""Stable IDs for script nodes and strings, and import of Ren'Py translations (tl/).
* every statement node gets a permanent ``id``; say nodes also get a ``tid``
(translation id) — Ren'Py's own id when the game's tl/ files know it;
* ``tl/<lang>/*.rpy`` is turned into per-language tables: dialogue lines keyed by
tid and a strings table (names, menu choices, ``_()``) keyed by source text.
"""
import hashlib
import re
from collections import Counter, defaultdict
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
# Ren'Py language names -> BCP-47
RENPY_LANGS = {
"english": "en", "russian": "ru", "ukrainian": "uk", "belarusian": "be", "german": "de",
"french": "fr", "spanish": "es", "spanish_latam": "es-419", "latam": "es-419",
"portuguese": "pt", "brazilian": "pt-BR", "portuguese_br": "pt-BR", "italian": "it",
"polish": "pl", "czech": "cs", "slovak": "sk", "dutch": "nl", "norwegian": "no",
"swedish": "sv", "danish": "da", "finnish": "fi", "turkish": "tr", "greek": "el",
"hungarian": "hu", "romanian": "ro", "bulgarian": "bg", "serbian": "sr", "croatian": "hr",
"bosnian": "bs", "japanese": "ja", "korean": "ko", "chinese": "zh-Hans",
"schinese": "zh-Hans", "tchinese": "zh-Hant", "chinese_traditional": "zh-Hant",
"thai": "th", "vietnamese": "vi", "indonesian": "id", "malay": "ms", "filipino": "fil",
"tagalog": "fil", "arabic": "ar", "hebrew": "he", "persian": "fa", "hindi": "hi",
"hinglish": "hi-Latn",
}
LANG_NAMES = {
"en": "English", "ru": "Русский", "uk": "Українська", "de": "Deutsch", "fr": "Français",
"es": "Español", "es-419": "Español (Latinoamérica)", "pt": "Português",
"pt-BR": "Português (Brasil)", "it": "Italiano", "pl": "Polski", "cs": "Čeština",
"nl": "Nederlands", "no": "Norsk", "tr": "Türkçe", "ja": "日本語", "ko": "한국어",
"zh-Hans": "简体中文", "zh-Hant": "繁體中文", "th": "ไทย", "vi": "Tiếng Việt",
"id": "Bahasa Indonesia", "bs": "Bosanski", "hi-Latn": "Hinglish",
}
_STOPWORDS = {
"en": {"the", "you", "and", "is", "to", "it", "what", "i'm", "that", "of"},
"fr": {"le", "la", "les", "et", "est", "je", "tu", "vous", "pas", "une", "des", "c'est"},
"de": {"der", "die", "und", "ist", "ich", "du", "nicht", "das", "ein", "zu"},
"es": {"el", "la", "que", "y", "es", "no", "un", "los", "por", "qué"},
"pt": {"o", "a", "que", "não", "é", "um", "uma", "você", "eu", "de"},
"it": {"il", "che", "non", "è", "un", "di", "sono", "io", "per", "la"},
}
_TAGS = re.compile(r"\{[^{}]*\}|\[[^\[\]]*\]")
_WORDS = re.compile(r"[^\W\d_]+(?:'[^\W\d_]+)?", re.UNICODE)
_SRC_COMMENT = re.compile(r"#\s*(?:game/)?(.+?\.rpym?):(\d+)\s*$")
_TRANSLATE = re.compile(r"^translate\s+(\w+)\s+([\w.]+)\s*:\s*$")
def lang_code(renpy_name: str) -> str:
return RENPY_LANGS.get(renpy_name.lower(), renpy_name.lower())
def short_hash(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()[:8]
@dataclass
class LocaleData:
renpy: str
lines: dict[str, str] = field(default_factory=dict) # tid -> text
strings: dict[str, str] = field(default_factory=dict) # source text -> text
unmatched: int = 0
def walk(nodes: list[dict[str, Any]]):
"""All statement nodes in a body, depth first, including menu captions."""
for n in nodes:
yield n
if n.get("op") == "menu" and n.get("say"):
yield n["say"]
for b in n.get("branches", []):
yield from walk(b["body"])
for it in n.get("items", []):
yield from walk(it["body"])
if n.get("op") == "while":
yield from walk(n["body"])
def assign_ids(modules: dict[str, list[dict[str, Any]]]) -> dict[tuple[str, int], dict]:
"""Give every node an ``id`` and say nodes a ``tid``.
Returns an index (file, line) -> say node used to attach translations.
"""
by_line: dict[tuple[str, int], dict] = {}
used: Counter = Counter()
for file, labels in modules.items():
n = 0
for label in labels:
group = label["name"]
for node in walk(label["body"]):
n += 1
node["id"] = short_hash(f"{file}:{n}")
if node.get("op") == "label":
group = node["name"]
if node.get("op") != "say":
continue
# Ren'Py style: <label>_<hash of the line>, de-duplicated
base = f"{group}_{short_hash((node.get('who') or '') + chr(0) + node['what'])}"
used[base] += 1
node["tid"] = base if used[base] == 1 else f"{base}_{used[base] - 1}"
node["_group"] = group
if "_ln" in node:
by_line[(file, node["_ln"])] = node
return by_line
def strip_temp(modules: dict[str, list[dict[str, Any]]]) -> None:
for labels in modules.values():
for label in labels:
for node in walk(label["body"]):
node.pop("_ln", None)
node.pop("_group", None)
def import_tl(game_dir: Path, modules: dict[str, list[dict[str, Any]]],
by_line: dict[tuple[str, int], dict], warn) -> dict[str, LocaleData]:
"""Read tl/<lang>/ of a game into per-language tables (lines are re-keyed to our tids)."""
tl_root = next((p for p in game_dir.iterdir() if p.is_dir() and p.name.lower() == "tl"), None)
if tl_root is None:
return {}
# fallback matching: Ren'Py id prefix is the label; compare the line text
by_group: dict[str, list[dict]] = defaultdict(list)
for labels in modules.values():
for label in labels:
for node in walk(label["body"]):
if node.get("op") == "say":
by_group[node.get("_group", label["name"])].append(node)
result: dict[str, LocaleData] = {}
for lang_dir in sorted(p for p in tl_root.iterdir() if p.is_dir()):
if lang_dir.name == "None":
continue # strings for the source language itself
loc = LocaleData(renpy=lang_dir.name)
for f in sorted(lang_dir.rglob("*.rpy")):
try:
_read_tl_file(f.read_text(encoding="utf-8", errors="replace"), loc, by_line,
by_group)
except ParseError as e:
warn(f"Перевод {lang_dir.name}: ошибка разбора", f"{f.name}:{e.lineno}")
if loc.lines or loc.strings:
result[lang_code(lang_dir.name)] = loc
if loc.unmatched:
warn(f"Перевод {lang_dir.name}: реплики без пары в скрипте ({loc.unmatched})")
return result
def _read_tl_file(text: str, loc: LocaleData, by_line, by_group) -> None:
tree = parse_tree(text)
raw_lines = text.replace("\r\n", "\n").split("\n")
for line in tree:
m = _TRANSLATE.match(line.text)
if not m:
continue
ident = m.group(2)
if ident == "strings":
_read_strings(line.block, loc)
continue
if ident == "python" or ident == "style":
continue
texts = [s for s in (_say_text(child) for child in line.block) if s is not None]
if not texts:
continue
translated = "\n".join(texts) if len(texts) > 1 else texts[0]
target = None
src = _source_location(raw_lines, line.lineno)
if src is not None:
target = by_line.get(src)
if target is None:
original = _source_comment(line.block, raw_lines)
group = ident.rsplit("_", 1)[0]
for node in by_group.get(group, []):
if original is not None and node["what"] == original:
target = node
break
if target is None:
loc.unmatched += 1
continue
loc.lines[target["tid"]] = translated
def _source_location(raw_lines: list[str], lineno: int) -> tuple[str, int] | None:
"""The `# game/file.rpy:N` comment Ren'Py writes above each translate block."""
for i in range(lineno - 2, max(lineno - 5, -1), -1):
m = _SRC_COMMENT.match(raw_lines[i].strip())
if m:
return m.group(1), int(m.group(2))
return None
def _source_comment(block: list[Line], raw_lines: list[str]) -> str | None:
# comments are stripped from logical lines; look at the raw text before the first line
if not block:
return None
for raw in raw_lines[max(0, block[0].lineno - 4):block[0].lineno - 1]:
s = raw.strip()
if s.startswith("#"):
c = Cursor(s[1:].strip())
c.dotted() if not (c.peek('"') or c.peek("'")) else None
while c.image_part() is not None and not (c.peek('"') or c.peek("'")):
pass
text = c.string()
if text is not None:
return text
return None
def _say_text(line: Line) -> str | None:
c = Cursor(line.text)
first = c.string()
if first is not None:
second = c.string() if not c.eol() else None
return second if second is not None else first
if c.dotted() is None:
return None
while True:
save = c.pos
if c.string_raw() is not None:
c.pos = save
break
if c.image_part() is None and not c.char("@"):
c.pos = save
break
return c.string()
def _read_strings(block: list[Line], loc: LocaleData) -> None:
old = None
for line in block:
c = Cursor(line.text)
kw = c.word()
if kw == "old":
old = c.string()
elif kw == "new" and old is not None:
new = c.string()
if new is not None and new != old:
loc.strings[old] = new
old = None
def detect_language(modules: dict[str, list[dict[str, Any]]]) -> str | None:
"""Guess the source language of the dialogue from a sample of lines."""
scores: Counter = Counter()
letters = Counter()
seen = 0
for labels in modules.values():
for label in labels:
for node in walk(label["body"]):
if node.get("op") != "say" or seen > 4000:
continue
seen += 1
text = _TAGS.sub(" ", node["what"]).lower()
for ch in text:
if "Ѐ" <= ch <= "ӿ":
letters["cyr"] += 1
elif "぀" <= ch <= "ヿ":
letters["ja"] += 1
elif "一" <= ch <= "鿿":
letters["zh"] += 1
elif ch.isalpha():
letters["lat"] += 1
for w in _WORDS.findall(text):
for lang, words in _STOPWORDS.items():
if w in words:
scores[lang] += 1
if not seen:
return None
total = sum(letters.values()) or 1
if letters["cyr"] / total > 0.4:
return "ru"
if letters["ja"] / total > 0.2:
return "ja"
if letters["zh"] / total > 0.3:
return "zh-Hans"
return scores.most_common(1)[0][0] if scores else None
+61 -1
View File
@@ -13,7 +13,7 @@ from typing import Any
from app.blobs import put_bytes, put_file
from app.config import get_settings
from app.converter import assets
from app.converter import assets, locales
from app.converter.archive import extract, find_game_dir
from app.converter.rpa import RpaError, extract_rpa
from app.converter.rpy_lexer import ParseError
@@ -118,6 +118,14 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
images = {name: _resolve_image(img, aliases, missing) for name, img in data.images.items()}
audio = {name: _resolve(path, aliases) or path for name, path in data.audio.items()}
progress("Извлечение текстов и переводов")
by_line = locales.assign_ids(data.modules)
translations = locales.import_tl(game_dir, data.modules, by_line, data.warn)
locales.strip_temp(data.modules)
language = data.config.get("language")
source_lang = (locales.lang_code(language) if isinstance(language, str)
else locales.detect_language(data.modules)) or "und"
scripts: list[str] = []
for rel, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(rel).with_suffix(".json"))
@@ -128,6 +136,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
files[logical] = {"hash": blob_id, "size": size}
scripts.append(logical)
locale_index = _write_locales(translations, data.modules, files)
gui = data.gui
resolution = gui.get("resolution") or [
data.config.get("screen_width") or 1280, data.config.get("screen_height") or 720]
@@ -150,6 +160,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"defaults": data.defaults,
"init": data.init_code,
"ui": _ui(gui, aliases),
"sourceLang": source_lang,
"locales": locale_index,
}
payload = json.dumps(game, ensure_ascii=False, separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
@@ -173,6 +185,10 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"media": counts,
"failedMedia": failed[:50],
"missingFiles": sorted(missing)[:100],
"sourceLang": source_lang,
"locales": {code: {"lines": meta["lines"], "strings": meta["strings_count"],
"progress": meta["progress"]}
for code, meta in locale_index.items()},
"warnings": [
{"message": msg, "count": n, "example": data.warning_examples.get(msg)}
for msg, n in data.warnings.most_common()
@@ -183,6 +199,50 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
return Result({"format": FORMAT, "files": files}, report, title, cover, total)
def _write_locales(translations: dict[str, "locales.LocaleData"],
modules: dict[str, list[dict[str, Any]]],
files: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Store each language as per-module line tables plus one strings table."""
tid_module: dict[str, str] = {}
tid_source: dict[str, str] = {}
for rel, labels in modules.items():
for label in labels:
for node in locales.walk(label["body"]):
if node.get("op") == "say":
tid_module[node["tid"]] = rel
tid_source[node["tid"]] = node["what"]
index: dict[str, dict[str, Any]] = {}
def put_json(path: str, value: Any) -> None:
payload = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
files[path] = {"hash": blob_id, "size": size}
for code, loc in sorted(translations.items()):
grouped: dict[str, dict[str, Any]] = {}
for tid, text in loc.lines.items():
grouped.setdefault(tid_module[tid], {})[tid] = {
"t": text, "src": locales.short_hash(tid_source[tid]), "state": "translated"}
module_files = {}
for rel, entries in grouped.items():
path = f"locales/{code}/" + str(PurePosixPath(rel).with_suffix(".json"))
put_json(path, entries)
module_files["script/" + str(PurePosixPath(rel).with_suffix(".json"))] = path
strings_path = f"locales/{code}/strings.json"
put_json(strings_path, {old: {"t": new, "state": "translated"}
for old, new in loc.strings.items()})
index[code] = {
"name": locales.LANG_NAMES.get(code, loc.renpy),
"renpy": loc.renpy,
"modules": module_files,
"strings": strings_path,
"lines": len(loc.lines),
"strings_count": len(loc.strings),
"progress": round(len(loc.lines) / len(tid_module), 3) if tid_module else 0,
}
return index
def _size(path: Path) -> int:
if path.is_file():
return path.stat().st_size
+6 -3
View File
@@ -228,8 +228,10 @@ class RpyParser:
if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)):
try:
return handler(line) or []
except ParseError:
raise
except ParseError as e:
# one odd statement (custom syntax, typo) must not drop the whole file
self.d.warn(f"Не разобран оператор {word}", f"{e.filename}:{e.lineno}")
return []
if word in SKIP_BLOCKS:
if word not in ("style", "translate"):
self.d.warn(f"Не поддерживается: {word}", self.where(line))
@@ -472,6 +474,7 @@ class RpyParser:
def st_default(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("default")
c.number() # optional init priority
name = c.dotted()
if not name or not c.char("="):
raise c.error("ожидалось default имя = значение")
@@ -1087,7 +1090,7 @@ class RpyParser:
what = c.string()
if what is None:
return None
node: Node = {"op": "say", "what": what}
node: Node = {"op": "say", "what": what, "_ln": line.lineno} # _ln: dropped after IDs
if who and who != "narrator":
node["who"] = who
if who_str is not None:
+28
View File
@@ -0,0 +1,28 @@
# TODO: Translation updated at 2026-10-25 12:00
# game/script.rpy:29
translate english start_1a2b3c4d:
# "Добро пожаловать в {b}тестовую{/b} новеллу."
"Welcome to the {b}test{/b} novel."
# game/script.rpy:32
translate english start_5e6f7a8b:
# e "Привет! Как тебя зовут?"
e "Hi! What's your name?"
# matched by label + original text, the location comment is stale
translate english walk_deadbeef:
# e sad "На улице дождь..."
e sad "It's raining outside..."
translate english strings:
# game/script.rpy:2
old "Эйлин"
new "Eileen"
old "Пойдём гулять"
new "Let's go for a walk"
+2
View File
@@ -59,6 +59,8 @@ def build(path: Path) -> Path:
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("SampleGame-1.0-pc/game/archive.rpa", rpa_path.read_bytes())
zf.writestr("SampleGame-1.0-pc/game/options.rpy", options)
zf.writestr("SampleGame-1.0-pc/game/tl/english/script.rpy",
(FIXTURES / "tl" / "english" / "script.rpy").read_bytes())
zf.writestr("SampleGame-1.0-pc/game/audio/theme.wav", _wav())
zf.writestr("SampleGame-1.0-pc/game/gui/main_menu.png",
_png((1280, 720), (30, 30, 50), "Main menu"))
+12 -1
View File
@@ -36,7 +36,7 @@ def test_say_variants():
d = parse('label start:\n "narration"\n e happy "hi" with dissolve\n'
' "Bob" "named"\n centered "c"\n')
body = d.modules["t.rpy"][0]["body"]
assert body[0] == {"op": "say", "what": "narration"}
assert {k: v for k, v in body[0].items() if k != "_ln"} == {"op": "say", "what": "narration"}
assert body[1]["who"] == "e" and body[1]["attrs"] == ["happy"] and body[1]["with"]["t"] == "dissolve"
assert body[2]["whoStr"] == "Bob"
assert body[3]["who"] == "centered"
@@ -80,6 +80,17 @@ def test_full_pipeline(data_dir, tmp_path):
if shutil.which("ffmpeg"):
assert "audio/theme.ogg" in files
# every node has an id, says have tids; tl/english imported into our tables
assert all("id" in n for n in script["labels"][0]["body"])
assert game["sourceLang"] == "ru"
en = game["locales"]["en"]
assert en["renpy"] == "english" and en["lines"] == 3
lines = json.loads(read_bytes(files[en["modules"]["script/script.json"]]["hash"]))
first_say = next(n for n in script["labels"][0]["body"] if n["op"] == "say")
assert lines[first_say["tid"]]["t"] == "Welcome to the {b}test{/b} novel."
strings = json.loads(read_bytes(files[en["strings"]]["hash"]))
assert strings["Эйлин"]["t"] == "Eileen"
def test_nested_labels_and_named_menus_become_anchors():
from app.converter.pipeline import anchors
+4 -1
View File
@@ -105,7 +105,10 @@ LOCKED`), Redis не нужен.
редактировать), состояние плеера — стек `{label, path}` где `path` — индексы
внутри вложенных блоков, так что сохранения стабильны и компактны.
Узлы (`op`): `say`, `scene`, `show`, `hide`, `with`, `play`, `stop`, `queue`,
У каждого узла есть постоянный `id`, у реплик — ещё `tid` для перевода.
Переводы лежат отдельно, в `locales/<язык>/…` (см. [LOCALIZATION.md](LOCALIZATION.md)).
Узлы (`op`): `label` (точка входа внутри блока), `camera`, `say`, `scene`, `show`, `hide`, `with`, `play`, `stop`, `queue`,
`pause`, `menu`, `if`, `while`, `jump`, `call`, `return`, `exec`, `window`,
`noop`. Выражения: `const`, `name`, `attr`, `sub`, `bin`, `bool`, `cmp`,
`unary`, `if`, `list`, `tuple`, `dict`, `call`, `fmt`.
+182
View File
@@ -0,0 +1,182 @@
# Локализация и моды — проект
Цель: переводить и модифицировать игры прямо в библиотеке, нативно и удобно.
Переводы из самих игр импортируются при конвертации, дальше живут только в
нашем формате.
## Почему это нужно решить сейчас
Исходники игры удаляются после конвертации. Всё, что не вытащено в момент
конвертации (готовые переводы из `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`:
```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/`.
* Машинный предперевод — **позже, как подключаемый провайдер**. Сейчас
закладывается только состояние строки `machine`.
* Начинаем с этапа 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 их тоже не показывает.
Уже сконвертированные версии остаются без ID и без переводов, их нужно
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
миграцию не делаем.
## Этапы
1. **Фундамент (сейчас, до наполнения библиотеки):** ID узлов и строк в
конвертере, импорт `tl/` всех языков, локали в манифесте, выбор языка и
запасные языки в плеере, `_()`/`!t`/`_preferences.language`.
Миграция уже сконвертированных версий: ID детерминированно выводятся
из текущего IR.
2. **Перевод в библиотеке:** главная версия, черновик и публикация, права,
редактор перевода, прогресс, экспорт и импорт PO/CSV.
3. **Удобство:** контекст сцены, «проиграть отсюда», глоссарий,
рецензирование, шрифты и ассеты локалей, машинный предперевод.
4. **Моды:** редактор скриптов, замена ассетов, слияние и перенос модов.
+66 -11
View File
@@ -4,6 +4,7 @@ import { AudioSystem, type Volumes } from "./audio";
import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
} from "./types";
@@ -110,6 +111,8 @@ export class Engine {
private saves: SaveApi,
private persistentKey: string,
private onExit: () => void,
private tr: Translator,
private loadLocale: (lang: string) => Promise<Translator>,
) {
for (const mod of modules) {
let global = "";
@@ -146,7 +149,7 @@ export class Engine {
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
evalCond: (c) => truthy(this.safeEval(c)),
transform: (name) => game.transforms[name] ?? null,
text: (e) => this.interp(pyStr(this.safeEval(e))),
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
});
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
this.buildUI();
@@ -346,8 +349,9 @@ export class Engine {
renpy,
persistent: this.persistent,
config: {},
preferences: {},
_preferences: {},
// games branch on the Ren'Py language name ("russian"); None means the source language
preferences: this.languagePrefs(),
_preferences: this.languagePrefs(),
gui: {},
store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null,
set: (_t, k: string, v) => ((this.store[k] = v), true) }),
@@ -386,10 +390,29 @@ export class Engine {
zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])),
isinstance: () => false,
_: (s: string) => s,
__: (s: string) => s,
__: (s: string) => this.tr.str(s),
};
}
private languagePrefs() {
const engine = this;
return {
get language() {
return engine.tr.renpyName;
},
};
}
private async setLanguage(lang: string) {
try {
this.tr = await this.loadLocale(lang);
rememberLanguage(lang);
this.notify(languageName(this.game, lang));
} catch (err) {
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
}
}
private resetStore() {
this.store = {};
try {
@@ -724,7 +747,7 @@ export class Engine {
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
const who: string | undefined = node.who;
if (node.whoStr !== undefined) return { char: {}, name: node.whoStr, mode: "adv" };
if (node.whoStr !== undefined) return { char: {}, name: this.interp(this.tr.str(node.whoStr)), mode: "adv" };
if (!who || who === "narrator") return { char: {}, name: "", mode: "adv" };
if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" };
if (who === "extend") return { char: this.lastSay?.char ?? {}, name: this.lastSay?.name ?? "", mode: "extend" };
@@ -734,14 +757,14 @@ export class Engine {
const v = this.store[who];
return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" };
}
let name = char.name ?? "";
if (char.nameExpr) name = pyStr(this.safeEval(char.nameExpr));
let name = this.tr.str(char.name ?? "");
if (char.nameExpr) name = this.tr.str(pyStr(this.safeEval(char.nameExpr)));
name = this.interp(name);
return { char, name: `${char.whoPrefix ?? ""}${name}${char.whoSuffix ?? ""}`, mode: char.nvl ? "nvl" : "adv" };
}
private interp(s: string): string {
return interpolate(s, (expr) => this.lookupPath(expr));
return interpolate(s, (expr) => this.lookupPath(expr), (v) => this.tr.str(v));
}
private lookupPath(expr: string): any {
@@ -766,7 +789,7 @@ export class Engine {
if (!inMenu) this.interactionStart();
if (node.with) await this.renderer.transition(this.transition(node.with));
let raw = this.interp(node.what);
let raw = this.interp(this.tr.line(node.tid, node.what));
raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
let prefixLen = 0;
if (mode === "extend" && this.lastSay) {
@@ -925,7 +948,7 @@ export class Engine {
this.menuEl.textContent = "";
for (const item of items) {
const b = el("button", "pg-choice");
renderText(b, parseText(this.interp(item.text)), Infinity, this.baseSize);
renderText(b, parseText(this.interp(this.tr.str(item.text))), Infinity, this.baseSize);
b.onclick = (ev) => {
ev.stopPropagation();
done(item.i);
@@ -936,7 +959,7 @@ export class Engine {
});
this.menuEl.classList.add("pg-hidden");
if (Array.isArray(chosen) && node.set) chosen.push(node.items[index].text);
this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(node.items[index].text)))}` });
this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(this.tr.str(node.items[index].text))))}` });
this.enter(index);
}
@@ -1243,11 +1266,33 @@ export class Engine {
}).catch(() => {});
btn("Загрузить", () => this.openSaves("load", true));
btn("Настройки", () => this.openSettings(true));
const langs = this.languages();
if (langs.length > 1) {
const select = this.languageSelect(langs, () => this.showMainMenu());
select.classList.add("pg-lang");
panel.append(select);
}
btn("В библиотеку", () => this.onExit());
this.screen.append(panel);
this.screen.classList.remove("pg-hidden");
}
private languages(): string[] {
return [this.game.sourceLang ?? "und", ...Object.keys(this.game.locales ?? {})];
}
private languageSelect(langs: string[], after: () => void): HTMLSelectElement {
const select = el("select", "pg-select");
for (const lang of langs) {
const o = el("option", "", languageName(this.game, lang) + progressLabel(this.game.locales?.[lang]?.progress));
o.value = lang;
o.selected = lang === this.tr.lang;
select.append(o);
}
select.onchange = () => void this.setLanguage(select.value).then(after);
return select;
}
private openGameMenu() {
this.openScreen("Меню", (body) => {
const list = el("div", "pg-menu-list");
@@ -1338,6 +1383,12 @@ export class Engine {
row("Музыка", 0, 1, 0.05, s.music, (v) => `${Math.round(v * 100)}%`, (v) => (s.music = v));
row("Звуки", 0, 1, 0.05, s.sound, (v) => `${Math.round(v * 100)}%`, (v) => (s.sound = v));
row("Голос", 0, 1, 0.05, s.voice, (v) => `${Math.round(v * 100)}%`, (v) => (s.voice = v));
const langs = this.languages();
if (langs.length > 1) {
const r = el("label", "pg-setting");
r.append(el("span", "", "Язык"), this.languageSelect(langs, () => {}), el("span", ""));
body.append(r);
}
const fs = el("button", "pg-wide", "Полный экран");
fs.onclick = () => {
if (document.fullscreenElement) void document.exitFullscreen();
@@ -1348,6 +1399,10 @@ export class Engine {
}
}
function progressLabel(p: number | undefined): string {
return p !== undefined && p < 0.995 ? ` — ${Math.round(p * 100)}%` : "";
}
function childBlock(node: Node, branch: number): Node[] {
if (node.op === "if") return node.branches[branch].body;
if (node.op === "menu") return node.items[branch].body;
+94
View File
@@ -0,0 +1,94 @@
// Game localisation: per-language line tables (by tid) and a strings table (by source text).
import type { GameData, Manifest } from "./types";
export interface LocaleInfo {
name: string;
renpy: string;
modules: Record<string, string>;
strings: string;
lines: number;
progress: number;
}
interface Entry {
t: string;
state?: string;
}
const PREF_KEY = "pgvn:lang";
export class Translator {
constructor(
public readonly lang: string, // BCP-47; equals sourceLang when untranslated
public readonly renpyName: string | null,
private lines: Map<string, string> = new Map(),
private strings: Map<string, string> = new Map(),
) {}
static source(game: GameData) {
return new Translator(game.sourceLang ?? "und", null);
}
/** Dialogue line by translation id, falling back to the source text. */
line(tid: string | undefined, source: string): string {
return (tid && this.lines.get(tid)) || source;
}
/** Names, menu choices, `_()` strings: looked up by their source text. */
str(source: string): string {
return this.strings.get(source) ?? source;
}
}
export function preferredLanguage(game: GameData): string {
const available = [game.sourceLang ?? "und", ...Object.keys(game.locales ?? {})];
let saved: string | null = null;
try {
saved = localStorage.getItem(PREF_KEY);
} catch {
/* storage unavailable */
}
if (saved && available.includes(saved)) return saved;
// browser languages, matched exactly or by primary subtag (ru-RU -> ru)
for (const nav of navigator.languages ?? [navigator.language]) {
const hit = available.find((a) => a === nav) ?? available.find((a) => a.split("-")[0] === nav.split("-")[0]);
if (hit) return hit;
}
return game.sourceLang ?? "und";
}
export function rememberLanguage(lang: string) {
try {
localStorage.setItem(PREF_KEY, lang);
} catch {
/* storage unavailable */
}
}
export async function loadTranslator(game: GameData, manifest: Manifest, lang: string): Promise<Translator> {
const info = game.locales?.[lang];
if (!info) return Translator.source(game);
const blob = (path: string) => {
const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`);
return fetch(`/blobs/${f.hash}`).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${path}`);
return r.json() as Promise<Record<string, Entry>>;
});
};
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
const lines = new Map<string, string>();
for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t);
return new Translator(lang, info.renpy, lines,
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])));
}
export function languageName(game: GameData, lang: string): string {
if (lang === game.sourceLang) {
const known: Record<string, string> = { en: "English", ru: "Русский", fr: "Français", de: "Deutsch",
es: "Español", ja: "日本語" };
return `${known[lang] ?? lang} (оригинал)`;
}
return game.locales?.[lang]?.name ?? lang;
}
+8 -2
View File
@@ -1,5 +1,6 @@
import "./player.css";
import { Engine, type SaveApi } from "./engine";
import { loadTranslator, preferredLanguage } from "./i18n";
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
async function getJSON<T>(url: string): Promise<T> {
@@ -44,9 +45,14 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`);
const modules = await Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s))));
const lang = preferredLanguage(game);
const [modules, tr] = await Promise.all([
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
loadTranslator(game, manifest, lang),
]);
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit);
`pgvn:persistent:${versionId}`, onExit, tr,
(code) => loadTranslator(game, manifest, code));
engine.start();
return engine;
}
+11
View File
@@ -193,3 +193,14 @@
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
.pg-main-panel button { text-align: left; }
.pg-select {
font: inherit;
font-size: 0.85em;
color: #fff;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.3em;
padding: 0.3em 0.6em;
}
.pg-select option { background: #151827; }
.pg-lang { margin-top: 0.6em; }
+1 -1
View File
@@ -152,7 +152,7 @@ export class Renderer {
if (def.text !== undefined || def.textExpr) {
const d = document.createElement("div");
d.className = "pg-show-text";
d.textContent = def.textExpr ? this.host.text(def.textExpr) : def.text!;
d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text });
return d;
}
if (def.frames?.length) return this.animated(def, depth);
+3 -1
View File
@@ -22,7 +22,8 @@ export interface ParsedText {
length: number; // number of visible characters
}
export function interpolate(text: string, lookup: (expr: string) => any): string {
export function interpolate(text: string, lookup: (expr: string) => any,
translate: (s: string) => string = (s) => s): string {
return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
if (m === "[[") return "[";
let src = expr!.trim();
@@ -33,6 +34,7 @@ export function interpolate(text: string, lookup: (expr: string) => any): string
src = src.slice(0, bang);
}
let v = pyStr(lookup(src));
if (flags.includes("t")) v = translate(v);
if (flags.includes("u")) v = v.toUpperCase();
if (flags.includes("l")) v = v.toLowerCase();
if (flags.includes("c")) v = v.charAt(0).toUpperCase() + v.slice(1);
+2
View File
@@ -107,6 +107,8 @@ export interface GameData {
defaults: [string, Expr][];
init: PyStmt[];
ui: Record<string, any>;
sourceLang?: string;
locales?: Record<string, import("./i18n").LocaleInfo>;
}
export interface Manifest {