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:
1 parent
5d9ddb6376
commit
e1d7b29f77
16 files changed
+772
-22
No files matched your search
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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. **Моды:** редактор скриптов, замена ассетов, слияние и перенос модов.
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user