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 collections import Counter
from pathlib import Path 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.archive import ArchiveError, find_game_dir
from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors from app.converter.pipeline import _auto_images, _is_engine_file, _resolve_image, anchors
from app.converter.rpa import RpaError, extract_rpa, read_index 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: except RecursionError:
data.warn("Слишком глубокая вложенность", rel) 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 # media aliases as the real pipeline would produce them
aliases = {k: str(Path(v).with_suffix(assets.target_ext(Path(v))).as_posix()) 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))} 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), "stats": dict(data.stats),
"media": dict(media), "media": dict(media),
"images": len(data.images), "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), "characters": len(data.characters),
"missingJumpTargets": sorted(t for t in targets if t not in labels)[:15], "missingJumpTargets": sorted(t for t in targets if t not in labels)[:15],
"missingFiles": sorted(missing)[: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.blobs import put_bytes, put_file
from app.config import get_settings 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.archive import extract, find_game_dir
from app.converter.rpa import RpaError, extract_rpa from app.converter.rpa import RpaError, extract_rpa
from app.converter.rpy_lexer import ParseError 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()} 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()} 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] = [] scripts: list[str] = []
for rel, labels in data.modules.items(): for rel, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(rel).with_suffix(".json")) 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} files[logical] = {"hash": blob_id, "size": size}
scripts.append(logical) scripts.append(logical)
locale_index = _write_locales(translations, data.modules, files)
gui = data.gui gui = data.gui
resolution = gui.get("resolution") or [ resolution = gui.get("resolution") or [
data.config.get("screen_width") or 1280, data.config.get("screen_height") or 720] 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, "defaults": data.defaults,
"init": data.init_code, "init": data.init_code,
"ui": _ui(gui, aliases), "ui": _ui(gui, aliases),
"sourceLang": source_lang,
"locales": locale_index,
} }
payload = json.dumps(game, ensure_ascii=False, separators=(",", ":")).encode() payload = json.dumps(game, ensure_ascii=False, separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json") blob_id, size = put_bytes(payload, ".json")
@@ -173,6 +185,10 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"media": counts, "media": counts,
"failedMedia": failed[:50], "failedMedia": failed[:50],
"missingFiles": sorted(missing)[:100], "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": [ "warnings": [
{"message": msg, "count": n, "example": data.warning_examples.get(msg)} {"message": msg, "count": n, "example": data.warning_examples.get(msg)}
for msg, n in data.warnings.most_common() 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) 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: def _size(path: Path) -> int:
if path.is_file(): if path.is_file():
return path.stat().st_size 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)): if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)):
try: try:
return handler(line) or [] return handler(line) or []
except ParseError: except ParseError as e:
raise # 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 in SKIP_BLOCKS:
if word not in ("style", "translate"): if word not in ("style", "translate"):
self.d.warn(f"Не поддерживается: {word}", self.where(line)) self.d.warn(f"Не поддерживается: {word}", self.where(line))
@@ -472,6 +474,7 @@ class RpyParser:
def st_default(self, line: Line) -> list[Node]: def st_default(self, line: Line) -> list[Node]:
c = self.cursor(line) c = self.cursor(line)
c.keyword("default") c.keyword("default")
c.number() # optional init priority
name = c.dotted() name = c.dotted()
if not name or not c.char("="): if not name or not c.char("="):
raise c.error("ожидалось default имя = значение") raise c.error("ожидалось default имя = значение")
@@ -1087,7 +1090,7 @@ class RpyParser:
what = c.string() what = c.string()
if what is None: if what is None:
return 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": if who and who != "narrator":
node["who"] = who node["who"] = who
if who_str is not None: 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: 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/archive.rpa", rpa_path.read_bytes())
zf.writestr("SampleGame-1.0-pc/game/options.rpy", options) 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/audio/theme.wav", _wav())
zf.writestr("SampleGame-1.0-pc/game/gui/main_menu.png", zf.writestr("SampleGame-1.0-pc/game/gui/main_menu.png",
_png((1280, 720), (30, 30, 50), "Main menu")) _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' d = parse('label start:\n "narration"\n e happy "hi" with dissolve\n'
' "Bob" "named"\n centered "c"\n') ' "Bob" "named"\n centered "c"\n')
body = d.modules["t.rpy"][0]["body"] 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[1]["who"] == "e" and body[1]["attrs"] == ["happy"] and body[1]["with"]["t"] == "dissolve"
assert body[2]["whoStr"] == "Bob" assert body[2]["whoStr"] == "Bob"
assert body[3]["who"] == "centered" assert body[3]["who"] == "centered"
@@ -80,6 +80,17 @@ def test_full_pipeline(data_dir, tmp_path):
if shutil.which("ffmpeg"): if shutil.which("ffmpeg"):
assert "audio/theme.ogg" in files 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(): def test_nested_labels_and_named_menus_become_anchors():
from app.converter.pipeline import anchors from app.converter.pipeline import anchors
+4 -1
View File
@@ -105,7 +105,10 @@ LOCKED`), Redis не нужен.
редактировать), состояние плеера — стек `{label, path}` где `path` — индексы редактировать), состояние плеера — стек `{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`, `pause`, `menu`, `if`, `while`, `jump`, `call`, `return`, `exec`, `window`,
`noop`. Выражения: `const`, `name`, `attr`, `sub`, `bin`, `bool`, `cmp`, `noop`. Выражения: `const`, `name`, `attr`, `sub`, `bin`, `bool`, `cmp`,
`unary`, `if`, `list`, `tuple`, `dict`, `call`, `fmt`. `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 { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer"; import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n";
import type { import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
} from "./types"; } from "./types";
@@ -110,6 +111,8 @@ export class Engine {
private saves: SaveApi, private saves: SaveApi,
private persistentKey: string, private persistentKey: string,
private onExit: () => void, private onExit: () => void,
private tr: Translator,
private loadLocale: (lang: string) => Promise<Translator>,
) { ) {
for (const mod of modules) { for (const mod of modules) {
let global = ""; let global = "";
@@ -146,7 +149,7 @@ export class Engine {
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null, resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
evalCond: (c) => truthy(this.safeEval(c)), evalCond: (c) => truthy(this.safeEval(c)),
transform: (name) => game.transforms[name] ?? null, 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.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
this.buildUI(); this.buildUI();
@@ -346,8 +349,9 @@ export class Engine {
renpy, renpy,
persistent: this.persistent, persistent: this.persistent,
config: {}, config: {},
preferences: {}, // games branch on the Ren'Py language name ("russian"); None means the source language
_preferences: {}, preferences: this.languagePrefs(),
_preferences: this.languagePrefs(),
gui: {}, gui: {},
store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null, store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null,
set: (_t, k: string, v) => ((this.store[k] = v), true) }), 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])), zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])),
isinstance: () => false, isinstance: () => false,
_: (s: string) => s, _: (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() { private resetStore() {
this.store = {}; this.store = {};
try { try {
@@ -724,7 +747,7 @@ export class Engine {
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } { private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
const who: string | undefined = node.who; 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 || who === "narrator") return { char: {}, name: "", mode: "adv" };
if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" }; if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" };
if (who === "extend") return { char: this.lastSay?.char ?? {}, name: this.lastSay?.name ?? "", mode: "extend" }; 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]; const v = this.store[who];
return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" }; return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" };
} }
let name = char.name ?? ""; let name = this.tr.str(char.name ?? "");
if (char.nameExpr) name = pyStr(this.safeEval(char.nameExpr)); if (char.nameExpr) name = this.tr.str(pyStr(this.safeEval(char.nameExpr)));
name = this.interp(name); name = this.interp(name);
return { char, name: `${char.whoPrefix ?? ""}${name}${char.whoSuffix ?? ""}`, mode: char.nvl ? "nvl" : "adv" }; return { char, name: `${char.whoPrefix ?? ""}${name}${char.whoSuffix ?? ""}`, mode: char.nvl ? "nvl" : "adv" };
} }
private interp(s: string): string { 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 { private lookupPath(expr: string): any {
@@ -766,7 +789,7 @@ export class Engine {
if (!inMenu) this.interactionStart(); if (!inMenu) this.interactionStart();
if (node.with) await this.renderer.transition(this.transition(node.with)); 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 ?? ""}`; raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
let prefixLen = 0; let prefixLen = 0;
if (mode === "extend" && this.lastSay) { if (mode === "extend" && this.lastSay) {
@@ -925,7 +948,7 @@ export class Engine {
this.menuEl.textContent = ""; this.menuEl.textContent = "";
for (const item of items) { for (const item of items) {
const b = el("button", "pg-choice"); 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) => { b.onclick = (ev) => {
ev.stopPropagation(); ev.stopPropagation();
done(item.i); done(item.i);
@@ -936,7 +959,7 @@ export class Engine {
}); });
this.menuEl.classList.add("pg-hidden"); this.menuEl.classList.add("pg-hidden");
if (Array.isArray(chosen) && node.set) chosen.push(node.items[index].text); 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); this.enter(index);
} }
@@ -1243,11 +1266,33 @@ export class Engine {
}).catch(() => {}); }).catch(() => {});
btn("Загрузить", () => this.openSaves("load", true)); btn("Загрузить", () => this.openSaves("load", true));
btn("Настройки", () => this.openSettings(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()); btn("В библиотеку", () => this.onExit());
this.screen.append(panel); this.screen.append(panel);
this.screen.classList.remove("pg-hidden"); 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() { private openGameMenu() {
this.openScreen("Меню", (body) => { this.openScreen("Меню", (body) => {
const list = el("div", "pg-menu-list"); 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.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.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)); 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", "Полный экран"); const fs = el("button", "pg-wide", "Полный экран");
fs.onclick = () => { fs.onclick = () => {
if (document.fullscreenElement) void document.exitFullscreen(); 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[] { function childBlock(node: Node, branch: number): Node[] {
if (node.op === "if") return node.branches[branch].body; if (node.op === "if") return node.branches[branch].body;
if (node.op === "menu") return node.items[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 "./player.css";
import { Engine, type SaveApi } from "./engine"; import { Engine, type SaveApi } from "./engine";
import { loadTranslator, preferredLanguage } from "./i18n";
import type { GameData, Manifest, SaveData, ScriptModule } from "./types"; import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
async function getJSON<T>(url: string): Promise<T> { 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 manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json")); const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`); 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), 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(); engine.start();
return engine; 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 h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
.pg-main-panel button { text-align: left; } .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) { if (def.text !== undefined || def.textExpr) {
const d = document.createElement("div"); const d = document.createElement("div");
d.className = "pg-show-text"; 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; return d;
} }
if (def.frames?.length) return this.animated(def, depth); 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 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) => { return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
if (m === "[[") return "["; if (m === "[[") return "[";
let src = expr!.trim(); let src = expr!.trim();
@@ -33,6 +34,7 @@ export function interpolate(text: string, lookup: (expr: string) => any): string
src = src.slice(0, bang); src = src.slice(0, bang);
} }
let v = pyStr(lookup(src)); let v = pyStr(lookup(src));
if (flags.includes("t")) v = translate(v);
if (flags.includes("u")) v = v.toUpperCase(); if (flags.includes("u")) v = v.toUpperCase();
if (flags.includes("l")) v = v.toLowerCase(); if (flags.includes("l")) v = v.toLowerCase();
if (flags.includes("c")) v = v.charAt(0).toUpperCase() + v.slice(1); 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][]; defaults: [string, Expr][];
init: PyStmt[]; init: PyStmt[];
ui: Record<string, any>; ui: Record<string, any>;
sourceLang?: string;
locales?: Record<string, import("./i18n").LocaleInfo>;
} }
export interface Manifest { export interface Manifest {