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
+4280 -3530

No files matched your search

+155 -144
View File
@@ -1,144 +1,155 @@
"""Dry run of the script part of the converter on unpacked games (no media work).
python -m app.converter.analyze /games/Some-Game [/games/Other ...]
Prints, per game, how much of the script was understood and the top warnings.
"""
import json
import re
import shutil
import sys
import tempfile
import time
from collections import Counter
from pathlib import Path
from app.converter import assets
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
from app.converter.rpy_lexer import ParseError
from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing
SCRIPT_EXTS = (".rpy", ".rpyc", ".rpym", ".rpymc")
def renpy_version(root: Path) -> str:
for vc in root.rglob("renpy/vc_version.py"):
m = re.search(r"version\s*=\s*u?['\"]([^'\"]+)", vc.read_text(errors="replace"))
if m:
return m.group(1)
for init in root.rglob("renpy/__init__.py"):
m = re.search(r"version_tuple\s*=\s*\(([^)]*)\)", init.read_text(errors="replace"))
if m:
return ".".join(x.strip() for x in m.group(1).split(",")[:3])
return "?"
def analyze(src: Path, dump: Path | None = None) -> dict:
started = time.monotonic()
game_dir = find_game_dir(src)
data = ScriptData()
work = Path(tempfile.mkdtemp(prefix="analyze-"))
try:
files: dict[str, str] = {} # lowercase path -> path, for asset resolution
for p in game_dir.rglob("*"):
if p.is_file():
rel = p.relative_to(game_dir).as_posix()
if p.suffix.lower() in SCRIPT_EXTS:
dest = work / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(p, dest)
elif p.suffix.lower() != ".rpa":
files[rel.lower()] = rel
rpa_errors = []
for rpa in game_dir.rglob("*.rpa"):
try:
for name in read_index(rpa):
if not name.lower().endswith(SCRIPT_EXTS):
files[name.lower()] = name
extract_rpa(rpa, work, only=lambda n: n.lower().endswith(SCRIPT_EXTS))
except RpaError as e:
rpa_errors.append(str(e))
decompiled = decompile_missing(work, data.warn)
if dump is not None:
shutil.copytree(work, dump / src.name, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.rpyc", "*.rpymc"))
rpy = sorted((p for p in work.rglob("*.rpy") if not _is_engine_file(work, p)),
key=lambda p: p.relative_to(work).as_posix().lower())
lines = 0
for p in rpy:
rel = p.relative_to(work).as_posix()
text = p.read_text(encoding="utf-8", errors="replace")
lines += text.count("\n")
try:
RpyParser(data, rel).parse(text)
except ParseError as e:
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
except RecursionError:
data.warn("Слишком глубокая вложенность", rel)
# 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))}
_auto_images(data, aliases)
missing: set[str] = set()
for img in data.images.values():
_resolve_image(img, aliases, missing)
labels = {lbl["name"] for mod in data.modules.values() for lbl in mod}
labels |= {name for mod in data.modules.values() for name in anchors(mod)}
targets = Counter()
_collect_targets(data.modules, targets)
media = Counter(assets.kind_of(Path(v)) for v in files.values() if assets.kind_of(Path(v)))
return {
"game": src.name,
"renpy": renpy_version(src),
"seconds": round(time.monotonic() - started, 1),
"rpyFiles": len(rpy),
"decompiled": decompiled,
"lines": lines,
"labels": len(labels),
"hasStart": "start" in labels,
"stats": dict(data.stats),
"media": dict(media),
"images": len(data.images),
"characters": len(data.characters),
"missingJumpTargets": sorted(t for t in targets if t not in labels)[:15],
"missingFiles": sorted(missing)[:15],
"rpaErrors": rpa_errors,
"warningsTotal": sum(data.warnings.values()),
"warnings": [{"n": n, "msg": m, "ex": data.warning_examples.get(m)}
for m, n in data.warnings.most_common(25)],
}
finally:
shutil.rmtree(work, ignore_errors=True)
def _collect_targets(modules, out: Counter) -> None:
def walk(nodes):
for n in nodes:
if n["op"] in ("jump", "call") and n.get("target"):
out[n["target"]] += 1
for b in n.get("branches", []):
walk(b["body"])
for it in n.get("items", []):
walk(it["body"])
walk(n.get("body", []))
for mod in modules.values():
for lbl in mod:
walk(lbl["body"])
if __name__ == "__main__":
args = sys.argv[1:]
dump = None
if args[:1] == ["--dump"]:
dump, args = Path(args[1]), args[2:]
for arg in args:
try:
result = analyze(Path(arg), dump)
except (ArchiveError, OSError) as e:
result = {"game": Path(arg).name, "error": str(e)}
print(json.dumps(result, ensure_ascii=False), flush=True)
"""Dry run of the script part of the converter on unpacked games (no media work).
python -m app.converter.analyze /games/Some-Game [/games/Other ...]
Prints, per game, how much of the script was understood and the top warnings.
"""
import json
import re
import shutil
import sys
import tempfile
import time
from collections import Counter
from pathlib import Path
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
from app.converter.rpy_lexer import ParseError
from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing
SCRIPT_EXTS = (".rpy", ".rpyc", ".rpym", ".rpymc")
def renpy_version(root: Path) -> str:
for vc in root.rglob("renpy/vc_version.py"):
m = re.search(r"version\s*=\s*u?['\"]([^'\"]+)", vc.read_text(errors="replace"))
if m:
return m.group(1)
for init in root.rglob("renpy/__init__.py"):
m = re.search(r"version_tuple\s*=\s*\(([^)]*)\)", init.read_text(errors="replace"))
if m:
return ".".join(x.strip() for x in m.group(1).split(",")[:3])
return "?"
def analyze(src: Path, dump: Path | None = None) -> dict:
started = time.monotonic()
game_dir = find_game_dir(src)
data = ScriptData()
work = Path(tempfile.mkdtemp(prefix="analyze-"))
try:
files: dict[str, str] = {} # lowercase path -> path, for asset resolution
for p in game_dir.rglob("*"):
if p.is_file():
rel = p.relative_to(game_dir).as_posix()
if p.suffix.lower() in SCRIPT_EXTS:
dest = work / rel
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(p, dest)
elif p.suffix.lower() != ".rpa":
files[rel.lower()] = rel
rpa_errors = []
for rpa in game_dir.rglob("*.rpa"):
try:
for name in read_index(rpa):
if not name.lower().endswith(SCRIPT_EXTS):
files[name.lower()] = name
extract_rpa(rpa, work, only=lambda n: n.lower().endswith(SCRIPT_EXTS))
except RpaError as e:
rpa_errors.append(str(e))
decompiled = decompile_missing(work, data.warn)
if dump is not None:
shutil.copytree(work, dump / src.name, dirs_exist_ok=True,
ignore=shutil.ignore_patterns("*.rpyc", "*.rpymc"))
rpy = sorted((p for p in work.rglob("*.rpy") if not _is_engine_file(work, p)),
key=lambda p: p.relative_to(work).as_posix().lower())
lines = 0
for p in rpy:
rel = p.relative_to(work).as_posix()
text = p.read_text(encoding="utf-8", errors="replace")
lines += text.count("\n")
try:
RpyParser(data, rel).parse(text)
except ParseError as e:
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
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))}
_auto_images(data, aliases)
missing: set[str] = set()
for img in data.images.values():
_resolve_image(img, aliases, missing)
labels = {lbl["name"] for mod in data.modules.values() for lbl in mod}
labels |= {name for mod in data.modules.values() for name in anchors(mod)}
targets = Counter()
_collect_targets(data.modules, targets)
media = Counter(assets.kind_of(Path(v)) for v in files.values() if assets.kind_of(Path(v)))
return {
"game": src.name,
"renpy": renpy_version(src),
"seconds": round(time.monotonic() - started, 1),
"rpyFiles": len(rpy),
"decompiled": decompiled,
"lines": lines,
"labels": len(labels),
"hasStart": "start" in labels,
"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],
"rpaErrors": rpa_errors,
"warningsTotal": sum(data.warnings.values()),
"warnings": [{"n": n, "msg": m, "ex": data.warning_examples.get(m)}
for m, n in data.warnings.most_common(25)],
}
finally:
shutil.rmtree(work, ignore_errors=True)
def _collect_targets(modules, out: Counter) -> None:
def walk(nodes):
for n in nodes:
if n["op"] in ("jump", "call") and n.get("target"):
out[n["target"]] += 1
for b in n.get("branches", []):
walk(b["body"])
for it in n.get("items", []):
walk(it["body"])
walk(n.get("body", []))
for mod in modules.values():
for lbl in mod:
walk(lbl["body"])
if __name__ == "__main__":
args = sys.argv[1:]
dump = None
if args[:1] == ["--dump"]:
dump, args = Path(args[1]), args[2:]
for arg in args:
try:
result = analyze(Path(arg), dump)
except (ArchiveError, OSError) as e:
result = {"game": Path(arg).name, "error": str(e)}
print(json.dumps(result, ensure_ascii=False), flush=True)
+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
+349 -289
View File
@@ -1,289 +1,349 @@
"""Full conversion: uploaded archive → blobs + version manifest."""
import json
import logging
import os
import shutil
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
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.archive import extract, find_game_dir
from app.converter.rpa import RpaError, extract_rpa
from app.converter.rpy_lexer import ParseError
from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing
log = logging.getLogger(__name__)
FORMAT = "pgvn/1"
Progress = Callable[[str], None]
@dataclass
class Result:
manifest: dict[str, Any]
report: dict[str, Any]
title: str | None
cover_source: str | None # blob id of an image suitable as a cover
size: int
def convert_archive(archive: Path, filename: str, work: Path, progress: Progress) -> Result:
"""Convert an uploaded archive, or an already unpacked game directory (left untouched)."""
started = time.monotonic()
if work.exists():
shutil.rmtree(work)
src_root = work / "src"
out_dir = work / "out"
out_dir.mkdir(parents=True)
data = ScriptData()
if archive.is_dir():
progress("Копирование файлов игры")
game_dir = src_root / "game"
shutil.copytree(find_game_dir(archive), game_dir)
else:
progress("Распаковка архива")
extract(archive, filename, src_root)
game_dir = find_game_dir(src_root)
rpas = sorted(game_dir.rglob("*.rpa"))
for i, rpa in enumerate(rpas, 1):
progress(f"Распаковка {rpa.name} ({i}/{len(rpas)})")
try:
extract_rpa(rpa, game_dir)
except RpaError as e:
data.warn(str(e))
rpa.unlink()
progress("Декомпиляция .rpyc")
decompiled = decompile_missing(game_dir, data.warn)
progress("Разбор скриптов")
rpy_files = sorted((p for p in game_dir.rglob("*.rpy") if not _is_engine_file(game_dir, p)),
key=lambda p: p.relative_to(game_dir).as_posix().lower())
for p in rpy_files:
rel = p.relative_to(game_dir).as_posix()
try:
RpyParser(data, rel).parse(p.read_text(encoding="utf-8", errors="replace"))
except ParseError as e:
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
except RecursionError:
data.warn("Слишком глубокая вложенность", rel)
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
files: dict[str, dict[str, Any]] = {}
aliases: dict[str, str] = {}
media = [p for p in game_dir.rglob("*")
if p.is_file() and assets.kind_of(p) and not _is_engine_file(game_dir, p)]
counts = {"image": 0, "audio": 0, "video": 0, "font": 0}
failed: list[str] = []
done = 0
def work_one(p: Path) -> tuple[str, str, str, int]:
out, ext = assets.convert(p, out_dir)
blob_id, size = put_file(out, ext, move=True)
rel = p.relative_to(game_dir).as_posix()
logical = str(PurePosixPath(rel).with_suffix(ext))
return rel, logical, blob_id, size
with ThreadPoolExecutor(max_workers=get_settings().worker_threads) as pool:
futures = {pool.submit(work_one, p): p for p in media}
for fut in as_completed(futures):
p = futures[fut]
done += 1
if done % 20 == 0 or done == len(media):
progress(f"Конвертация медиа {done}/{len(media)}")
try:
rel, logical, blob_id, size = fut.result()
except Exception as e: # noqa: BLE001 — one broken file must not fail the game
failed.append(p.name)
data.warn("Не удалось сконвертировать файл", f"{p.name}: {e}")
continue
files[logical] = {"hash": blob_id, "size": size}
aliases[rel.lower()] = logical
counts[assets.kind_of(p)] += 1
progress("Сборка формата PGVN")
_auto_images(data, aliases)
missing: set[str] = set()
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()}
scripts: list[str] = []
for rel, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(rel).with_suffix(".json"))
payload = json.dumps({"file": rel, "labels": labels, "anchors": anchors(labels)},
ensure_ascii=False,
separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
files[logical] = {"hash": blob_id, "size": size}
scripts.append(logical)
gui = data.gui
resolution = gui.get("resolution") or [
data.config.get("screen_width") or 1280, data.config.get("screen_height") or 720]
title = data.config.get("name") if isinstance(data.config.get("name"), str) else None
game = {
"format": FORMAT,
"engine": "renpy",
"title": title,
"version": data.config.get("version"),
"resolution": resolution,
"entry": "start",
"scripts": scripts,
"characters": data.characters,
"images": images,
"transforms": data.transforms,
"transitions": data.transitions,
"audio": audio,
"channels": data.channels,
"files": aliases,
"defaults": data.defaults,
"init": data.init_code,
"ui": _ui(gui, aliases),
}
payload = json.dumps(game, ensure_ascii=False, separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
files["game.json"] = {"hash": blob_id, "size": size}
cover = None
for candidate in ("gui/main_menu.png", "gui/main_menu.jpg", "gui/main_menu.webp",
"gui/window_icon.png"):
if (logical := aliases.get(candidate)) and logical in files:
cover = files[logical]["hash"]
break
total = sum(f["size"] for f in files.values())
report = {
"format": FORMAT,
"durationSec": round(time.monotonic() - started, 1),
"scripts": len(rpy_files),
"decompiled": decompiled,
"labels": sum(len(v) for v in data.modules.values()),
"stats": dict(data.stats),
"media": counts,
"failedMedia": failed[:50],
"missingFiles": sorted(missing)[:100],
"warnings": [
{"message": msg, "count": n, "example": data.warning_examples.get(msg)}
for msg, n in data.warnings.most_common()
],
"sourceSize": _size(archive),
"outputSize": total,
}
return Result({"format": FORMAT, "files": files}, report, title, cover, total)
def _size(path: Path) -> int:
if path.is_file():
return path.stat().st_size
return sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
def _is_engine_file(game_dir: Path, p: Path) -> bool:
parts = [x.lower() for x in p.relative_to(game_dir).parts]
return len(parts) > 1 and parts[0] in ("cache", "saves", "tl", "renpy")
def _resolve(path: str, aliases: dict[str, str]) -> str | None:
key = path.replace("\\", "/").lower().lstrip("/")
for prefix in ("", "images/", "audio/", "music/", "sound/", "sounds/"):
if (hit := aliases.get(prefix + key)) is not None:
return hit
return None
def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[str]
) -> dict[str, Any]:
img = dict(img)
for key in ("file", "movie"):
if isinstance(img.get(key), str):
hit = _resolve(img[key], aliases)
if hit is None:
missing.add(img[key])
else:
img[key] = hit
if "switch" in img:
img["switch"] = [[c, _resolve_image(sub, aliases, missing)] for c, sub in img["switch"]]
if "composite" in img:
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
for layer in img["composite"]]
if "frames" in img:
img["frames"] = [{**f, "img": _resolve_image(f["img"], aliases, missing)}
for f in img["frames"]]
if "layered" in img:
layers = []
for layer in img["layered"]:
layer = dict(layer)
for key in ("always", "img"):
if key in layer:
layer[key] = _resolve_image(layer[key], aliases, missing)
if "cond" in layer:
layer["cond"] = [[c, _resolve_image(sub, aliases, missing)]
for c, sub in layer["cond"]]
layers.append(layer)
img["layered"] = layers
return img
def anchors(labels: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Positions of `label` markers (nested labels, named menus) inside label bodies."""
found: dict[str, dict[str, Any]] = {}
def walk(nodes: list[dict[str, Any]], top: str, path: list[int]) -> None:
for i, n in enumerate(nodes):
here = path + [i]
if n["op"] == "label":
found[n["name"]] = {"label": top, "path": here, "params": n.get("params")}
if n["op"] == "if":
for b, branch in enumerate(n["branches"]):
walk(branch["body"], top, here + [b])
elif n["op"] == "menu":
for b, item in enumerate(n["items"]):
walk(item["body"], top, here + [b])
elif n["op"] == "while":
walk(n["body"], top, here + [0])
for label in labels:
walk(label["body"], label["name"], [])
return found
def _auto_images(data: ScriptData, aliases: dict[str, str]) -> None:
"""Ren'Py defines an image for every file under images/ (name = lowercase basename)."""
for rel, logical in aliases.items():
if not rel.startswith("images/") or assets.kind_of(Path(rel)) != "image":
continue
name = os.path.splitext(os.path.basename(rel))[0].lower().strip()
name = " ".join(name.split())
if name and name not in data.images:
data.images[name] = {"file": rel}
def _ui(gui: dict[str, Any], aliases: dict[str, str]) -> dict[str, Any]:
ui: dict[str, Any] = {}
for key, rel in (("textbox", "gui/textbox.png"), ("namebox", "gui/namebox.png"),
("mainMenu", "gui/main_menu.png"), ("nvl", "gui/nvl.png"),
("choice", "gui/button/choice_idle_background.png"),
("choiceHover", "gui/button/choice_hover_background.png")):
if hit := aliases.get(rel):
ui[key] = hit
for key in ("text_font", "name_text_font"):
if isinstance(gui.get(key), str) and (hit := _resolve(gui[key], aliases)):
ui[key] = hit
for key in ("text_size", "name_text_size", "text_color", "accent_color", "textbox_height",
"textbox_yalign", "dialogue_xpos", "dialogue_ypos", "dialogue_width",
"name_xpos", "name_ypos", "interface_text_color", "idle_color", "hover_color",
"choice_button_text_idle_color", "choice_button_text_hover_color"):
if isinstance(gui.get(key), (int, float, str)):
ui[key] = gui[key]
return ui
"""Full conversion: uploaded archive → blobs + version manifest."""
import json
import logging
import os
import shutil
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from pathlib import Path, PurePosixPath
from typing import Any
from app.blobs import put_bytes, put_file
from app.config import get_settings
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
from app.converter.rpy_parser import RpyParser, ScriptData
from app.converter.rpyc import decompile_missing
log = logging.getLogger(__name__)
FORMAT = "pgvn/1"
Progress = Callable[[str], None]
@dataclass
class Result:
manifest: dict[str, Any]
report: dict[str, Any]
title: str | None
cover_source: str | None # blob id of an image suitable as a cover
size: int
def convert_archive(archive: Path, filename: str, work: Path, progress: Progress) -> Result:
"""Convert an uploaded archive, or an already unpacked game directory (left untouched)."""
started = time.monotonic()
if work.exists():
shutil.rmtree(work)
src_root = work / "src"
out_dir = work / "out"
out_dir.mkdir(parents=True)
data = ScriptData()
if archive.is_dir():
progress("Копирование файлов игры")
game_dir = src_root / "game"
shutil.copytree(find_game_dir(archive), game_dir)
else:
progress("Распаковка архива")
extract(archive, filename, src_root)
game_dir = find_game_dir(src_root)
rpas = sorted(game_dir.rglob("*.rpa"))
for i, rpa in enumerate(rpas, 1):
progress(f"Распаковка {rpa.name} ({i}/{len(rpas)})")
try:
extract_rpa(rpa, game_dir)
except RpaError as e:
data.warn(str(e))
rpa.unlink()
progress("Декомпиляция .rpyc")
decompiled = decompile_missing(game_dir, data.warn)
progress("Разбор скриптов")
rpy_files = sorted((p for p in game_dir.rglob("*.rpy") if not _is_engine_file(game_dir, p)),
key=lambda p: p.relative_to(game_dir).as_posix().lower())
for p in rpy_files:
rel = p.relative_to(game_dir).as_posix()
try:
RpyParser(data, rel).parse(p.read_text(encoding="utf-8", errors="replace"))
except ParseError as e:
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
except RecursionError:
data.warn("Слишком глубокая вложенность", rel)
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
files: dict[str, dict[str, Any]] = {}
aliases: dict[str, str] = {}
media = [p for p in game_dir.rglob("*")
if p.is_file() and assets.kind_of(p) and not _is_engine_file(game_dir, p)]
counts = {"image": 0, "audio": 0, "video": 0, "font": 0}
failed: list[str] = []
done = 0
def work_one(p: Path) -> tuple[str, str, str, int]:
out, ext = assets.convert(p, out_dir)
blob_id, size = put_file(out, ext, move=True)
rel = p.relative_to(game_dir).as_posix()
logical = str(PurePosixPath(rel).with_suffix(ext))
return rel, logical, blob_id, size
with ThreadPoolExecutor(max_workers=get_settings().worker_threads) as pool:
futures = {pool.submit(work_one, p): p for p in media}
for fut in as_completed(futures):
p = futures[fut]
done += 1
if done % 20 == 0 or done == len(media):
progress(f"Конвертация медиа {done}/{len(media)}")
try:
rel, logical, blob_id, size = fut.result()
except Exception as e: # noqa: BLE001 — one broken file must not fail the game
failed.append(p.name)
data.warn("Не удалось сконвертировать файл", f"{p.name}: {e}")
continue
files[logical] = {"hash": blob_id, "size": size}
aliases[rel.lower()] = logical
counts[assets.kind_of(p)] += 1
progress("Сборка формата PGVN")
_auto_images(data, aliases)
missing: set[str] = set()
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"))
payload = json.dumps({"file": rel, "labels": labels, "anchors": anchors(labels)},
ensure_ascii=False,
separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json")
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]
title = data.config.get("name") if isinstance(data.config.get("name"), str) else None
game = {
"format": FORMAT,
"engine": "renpy",
"title": title,
"version": data.config.get("version"),
"resolution": resolution,
"entry": "start",
"scripts": scripts,
"characters": data.characters,
"images": images,
"transforms": data.transforms,
"transitions": data.transitions,
"audio": audio,
"channels": data.channels,
"files": aliases,
"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")
files["game.json"] = {"hash": blob_id, "size": size}
cover = None
for candidate in ("gui/main_menu.png", "gui/main_menu.jpg", "gui/main_menu.webp",
"gui/window_icon.png"):
if (logical := aliases.get(candidate)) and logical in files:
cover = files[logical]["hash"]
break
total = sum(f["size"] for f in files.values())
report = {
"format": FORMAT,
"durationSec": round(time.monotonic() - started, 1),
"scripts": len(rpy_files),
"decompiled": decompiled,
"labels": sum(len(v) for v in data.modules.values()),
"stats": dict(data.stats),
"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()
],
"sourceSize": _size(archive),
"outputSize": 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:
if path.is_file():
return path.stat().st_size
return sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
def _is_engine_file(game_dir: Path, p: Path) -> bool:
parts = [x.lower() for x in p.relative_to(game_dir).parts]
return len(parts) > 1 and parts[0] in ("cache", "saves", "tl", "renpy")
def _resolve(path: str, aliases: dict[str, str]) -> str | None:
key = path.replace("\\", "/").lower().lstrip("/")
for prefix in ("", "images/", "audio/", "music/", "sound/", "sounds/"):
if (hit := aliases.get(prefix + key)) is not None:
return hit
return None
def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[str]
) -> dict[str, Any]:
img = dict(img)
for key in ("file", "movie"):
if isinstance(img.get(key), str):
hit = _resolve(img[key], aliases)
if hit is None:
missing.add(img[key])
else:
img[key] = hit
if "switch" in img:
img["switch"] = [[c, _resolve_image(sub, aliases, missing)] for c, sub in img["switch"]]
if "composite" in img:
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
for layer in img["composite"]]
if "frames" in img:
img["frames"] = [{**f, "img": _resolve_image(f["img"], aliases, missing)}
for f in img["frames"]]
if "layered" in img:
layers = []
for layer in img["layered"]:
layer = dict(layer)
for key in ("always", "img"):
if key in layer:
layer[key] = _resolve_image(layer[key], aliases, missing)
if "cond" in layer:
layer["cond"] = [[c, _resolve_image(sub, aliases, missing)]
for c, sub in layer["cond"]]
layers.append(layer)
img["layered"] = layers
return img
def anchors(labels: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Positions of `label` markers (nested labels, named menus) inside label bodies."""
found: dict[str, dict[str, Any]] = {}
def walk(nodes: list[dict[str, Any]], top: str, path: list[int]) -> None:
for i, n in enumerate(nodes):
here = path + [i]
if n["op"] == "label":
found[n["name"]] = {"label": top, "path": here, "params": n.get("params")}
if n["op"] == "if":
for b, branch in enumerate(n["branches"]):
walk(branch["body"], top, here + [b])
elif n["op"] == "menu":
for b, item in enumerate(n["items"]):
walk(item["body"], top, here + [b])
elif n["op"] == "while":
walk(n["body"], top, here + [0])
for label in labels:
walk(label["body"], label["name"], [])
return found
def _auto_images(data: ScriptData, aliases: dict[str, str]) -> None:
"""Ren'Py defines an image for every file under images/ (name = lowercase basename)."""
for rel, logical in aliases.items():
if not rel.startswith("images/") or assets.kind_of(Path(rel)) != "image":
continue
name = os.path.splitext(os.path.basename(rel))[0].lower().strip()
name = " ".join(name.split())
if name and name not in data.images:
data.images[name] = {"file": rel}
def _ui(gui: dict[str, Any], aliases: dict[str, str]) -> dict[str, Any]:
ui: dict[str, Any] = {}
for key, rel in (("textbox", "gui/textbox.png"), ("namebox", "gui/namebox.png"),
("mainMenu", "gui/main_menu.png"), ("nvl", "gui/nvl.png"),
("choice", "gui/button/choice_idle_background.png"),
("choiceHover", "gui/button/choice_hover_background.png")):
if hit := aliases.get(rel):
ui[key] = hit
for key in ("text_font", "name_text_font"):
if isinstance(gui.get(key), str) and (hit := _resolve(gui[key], aliases)):
ui[key] = hit
for key in ("text_size", "name_text_size", "text_color", "accent_color", "textbox_height",
"textbox_yalign", "dialogue_xpos", "dialogue_ypos", "dialogue_width",
"name_xpos", "name_ypos", "interface_text_color", "idle_color", "hover_color",
"choice_button_text_idle_color", "choice_button_text_hover_color"):
if isinstance(gui.get(key), (int, float, str)):
ui[key] = gui[key]
return ui
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
# TODO: Translation updated at 2026-10-25 12:00
# game/script.rpy:29
translate english start_1a2b3c4d:
# "Добро пожаловать в {b}тестовую{/b} новеллу."
"Welcome to the {b}test{/b} novel."
# game/script.rpy:32
translate english start_5e6f7a8b:
# e "Привет! Как тебя зовут?"
e "Hi! What's your name?"
# matched by label + original text, the location comment is stale
translate english walk_deadbeef:
# e sad "На улице дождь..."
e sad "It's raining outside..."
translate english strings:
# game/script.rpy:2
old "Эйлин"
new "Eileen"
old "Пойдём гулять"
new "Let's go for a walk"
+2
View File
@@ -59,6 +59,8 @@ def build(path: Path) -> Path:
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("SampleGame-1.0-pc/game/archive.rpa", rpa_path.read_bytes())
zf.writestr("SampleGame-1.0-pc/game/options.rpy", options)
zf.writestr("SampleGame-1.0-pc/game/tl/english/script.rpy",
(FIXTURES / "tl" / "english" / "script.rpy").read_bytes())
zf.writestr("SampleGame-1.0-pc/game/audio/theme.wav", _wav())
zf.writestr("SampleGame-1.0-pc/game/gui/main_menu.png",
_png((1280, 720), (30, 30, 50), "Main menu"))
+12 -1
View File
@@ -36,7 +36,7 @@ def test_say_variants():
d = parse('label start:\n "narration"\n e happy "hi" with dissolve\n'
' "Bob" "named"\n centered "c"\n')
body = d.modules["t.rpy"][0]["body"]
assert body[0] == {"op": "say", "what": "narration"}
assert {k: v for k, v in body[0].items() if k != "_ln"} == {"op": "say", "what": "narration"}
assert body[1]["who"] == "e" and body[1]["attrs"] == ["happy"] and body[1]["with"]["t"] == "dissolve"
assert body[2]["whoStr"] == "Bob"
assert body[3]["who"] == "centered"
@@ -80,6 +80,17 @@ def test_full_pipeline(data_dir, tmp_path):
if shutil.which("ffmpeg"):
assert "audio/theme.ogg" in files
# every node has an id, says have tids; tl/english imported into our tables
assert all("id" in n for n in script["labels"][0]["body"])
assert game["sourceLang"] == "ru"
en = game["locales"]["en"]
assert en["renpy"] == "english" and en["lines"] == 3
lines = json.loads(read_bytes(files[en["modules"]["script/script.json"]]["hash"]))
first_say = next(n for n in script["labels"][0]["body"] if n["op"] == "say")
assert lines[first_say["tid"]]["t"] == "Welcome to the {b}test{/b} novel."
strings = json.loads(read_bytes(files[en["strings"]]["hash"]))
assert strings["Эйлин"]["t"] == "Eileen"
def test_nested_labels_and_named_menus_become_anchors():
from app.converter.pipeline import anchors
+4 -1
View File
@@ -105,7 +105,10 @@ LOCKED`), Redis не нужен.
редактировать), состояние плеера — стек `{label, path}` где `path` — индексы
внутри вложенных блоков, так что сохранения стабильны и компактны.
Узлы (`op`): `say`, `scene`, `show`, `hide`, `with`, `play`, `stop`, `queue`,
У каждого узла есть постоянный `id`, у реплик — ещё `tid` для перевода.
Переводы лежат отдельно, в `locales/<язык>/…` (см. [LOCALIZATION.md](LOCALIZATION.md)).
Узлы (`op`): `label` (точка входа внутри блока), `camera`, `say`, `scene`, `show`, `hide`, `with`, `play`, `stop`, `queue`,
`pause`, `menu`, `if`, `while`, `jump`, `call`, `return`, `exec`, `window`,
`noop`. Выражения: `const`, `name`, `attr`, `sub`, `bin`, `bool`, `cmp`,
`unary`, `if`, `list`, `tuple`, `dict`, `call`, `fmt`.
+182
View File
@@ -0,0 +1,182 @@
# Локализация и моды — проект
Цель: переводить и модифицировать игры прямо в библиотеке, нативно и удобно.
Переводы из самих игр импортируются при конвертации, дальше живут только в
нашем формате.
## Почему это нужно решить сейчас
Исходники игры удаляются после конвертации. Всё, что не вытащено в момент
конвертации (готовые переводы из `tl/`, привязка реплик к их месту в
исходнике), потом взять будет неоткуда. Поэтому стабильные идентификаторы
и импорт переводов должны появиться в конвертере до того, как библиотека
наполнится играми.
## Что есть в играх (по примерам)
| Игра | Языки в `tl/` | Переведённых реплик |
|---|---|---|
| That New Teacher | 19 (в т.ч. russian) | ~350 000 блоков |
| Twisted Thoughts | 9; исходник на французском, english — перевод | ~117 000 |
| остальные 4 | только исходный | — |
Формат Ren'Py:
```
# game/Days/dayEight.rpy:248
translate russian D8_Clarissa_c7855708:
# "You lie on your side, facing away..."
"Ты лежишь на боку, отвернувшись..."
translate russian strings:
old "Iris"
new "Айрис"
```
Реплики переводятся блоками с идентификатором (`метка_хэш`) и ссылкой на
строку оригинала; имена, пункты меню и строки интерфейса — таблицей old/new.
Бывает и игровая логика, зависящая от языка (`_preferences.language`).
## Главная идея: текст отдельно от скрипта, у всего стабильные ID
1. **ID узлов.** Каждый оператор скрипта получает постоянный `id`. Он
присваивается один раз при конвертации и дальше не меняется при правках.
На этих ID держатся переводы, сохранения и в будущем слияние модов.
2. **ID строк.** Реплики получают `tid` в стиле Ren'Py (`метка_хэш`) и
хранят его в скрипте, так что правки текста ID не меняют. Пункты меню,
имена, `_()` и `show text` переводятся таблицей строк по исходному тексту,
как в Ren'Py.
3. **Исходный текст остаётся в скрипте.** Скрипт читается как есть, язык
оригинала — это просто «исходная локаль» (у Twisted Thoughts — французский).
4. **Переводы — отдельные таблицы** `locales/<язык>/<модуль>.json`:
```json
{ "start_577f33b3": { "t": "Ммм...", "src": "a1b2c3", "state": "translated" } }
```
`src` — хэш исходного текста на момент перевода. Если в моде или новой
версии оригинал реплики изменился, перевод помечается «устарел», а не
теряется молча. Состояния: `translated`, `fuzzy`/устарел, `machine`,
`approved`.
5. **Файлы локали режутся по модулям скрипта.** Правка одной реплики создаёт
один новый маленький блоб — остальное общее (наш content-addressed store).
`locales/<язык>/meta.json`: код языка (BCP-47: `ru`), название, авторы,
прогресс, шрифт-замена (у многих игр в шрифтах нет кириллицы или CJK),
переопределения ассетов для языка: картинки с надписями, озвучка.
## Импорт переводов из игры
При конвертации разбираем `tl/<lang>/`:
* блок `translate <lang> <id>` сопоставляем с нашей репликой по комментарию
`# game/file.rpy:строка` (точно и просто), запасной вариант — по ID Ren'Py;
* `translate <lang> strings` превращаем в таблицу old→new для имён, меню и `_()`;
* языки Ren'Py (`russian`, `spanish_latam`, `None`) переводим в коды `ru`,
`es-419` и исходную локаль;
* импортируются все языки игры — это только текст, места почти не занимает.
## Плеер
* Выбор языка в главном меню и настройках, запоминается для каждого
пользователя.
* Цепочка запасных языков: `ru → en → исходный`. Непереведённая строка
показывается на запасном языке и не ломает игру.
* `_()`, флаг `!t` в `[переменных]`, имена персонажей переводятся из таблиц;
`_preferences.language` отдаёт выбранный язык, поэтому языковая логика
игры (как в Twisted Thoughts) работает.
* Шрифт и ассеты подменяются из `meta.json` локали.
## Версии и публикация
Сценарий:
1. Модератор делает форк главной версии: «Перевод RU (черновик)».
2. Переводит в редакторе. Форк виден только ему и тем, у кого есть права
на рецензию.
3. Нажимает «Опубликовать перевод».
**Публикация переносит локаль, а не подменяет версию.** Если два
переводчика параллельно делают RU и DE в двух форках, «сделать форк главным»
потеряет работу одного из них. Поэтому публикация создаёт новую главную
версию: главная + локаль из форка. Переводы склеиваются по `tid` построчно,
это тривиально. Перенос перевода в любую совместимую версию (например, в мод)
работает так же — одной кнопкой, по тем же ID.
Модели:
* `games.main_version_id` — главная версия (её открывает кнопка «Играть»);
* у версии `visibility`: `draft` / `published`, а также `kind`: оригинал,
перевод, мод;
* новые права: `translations.edit` — переводить в своих форках,
`versions.publish` — публиковать, назначать главную.
## Редактор перевода
* Таблица строк по сценам и меткам: оригинал | перевод | статус, фильтры
«не переведено / устарело / машинное», поиск, прогресс в процентах.
* Контекст строки: кто говорит, соседние реплики, фон и спрайты сцены на этом
месте, кнопка «проиграть с этой строки» в плеере.
* Глоссарий: имена и термины подставляются и проверяются на единообразие.
* Экспорт и импорт PO/XLIFF/CSV для внешних инструментов, экспорт обратно
в формат Ren'Py `tl/`.
* Позже: режим «править прямо в игре» — клик по реплике в плеере;
машинный предперевод с пометкой `machine`.
## Моды
Те же основы делают моды нативными:
* скрипт редактируется на уровне меток и операторов с постоянными ID;
* мод — форк с изменёнными модулями скрипта и ассетами: в хранилище только
отличия;
* переводы оригинала автоматически применяются к неизменённым репликам мода,
новые реплики мода видны как непереведённые;
* так как у всего есть ID и родитель, можно делать трёхстороннее слияние:
мод A + мод B, или перенос мода на новую версию оригинала.
## Принятые решения
* Публикация перевода — **слияние локали** в новую главную версию, а не
подмена главной версии форком.
* Из игр импортируются **все языки** `tl/`.
* Машинный предперевод — **позже, как подключаемый провайдер**. Сейчас
закладывается только состояние строки `machine`.
* Начинаем с этапа 1.
## Состояние
Этап 1 сделан:
* у всех узлов скрипта есть `id`, у реплик — `tid`;
* при конвертации импортируются все языки из `tl/`. Блок сопоставляется
с репликой по комментарию `# game/file.rpy:N`, а если он устарел — по
метке и исходному тексту. Отдельные таблицы `strings` тоже импортируются;
* исходный язык берётся из `config.language` или определяется по тексту
(у Twisted Thoughts — французский);
* в манифесте лежат `locales/<код>/<модуль>.json` и `locales/<код>/strings.json`,
в `game.json` — `sourceLang` и `locales` с прогрессом;
* в плеере есть выбор языка в главном меню и настройках, запасной язык —
исходный, работают `__()`, `!t`, `_preferences.language`.
Проверено на примерах: Twisted Thoughts — 9 языков, по 12 949 реплик,
100% сопоставлено. That New Teacher — 18 языков, около 75% реплик: остальное
авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым
английским репликам. Ren'Py их тоже не показывает.
Уже сконвертированные версии остаются без ID и без переводов, их нужно
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
миграцию не делаем.
## Этапы
1. **Фундамент (сейчас, до наполнения библиотеки):** ID узлов и строк в
конвертере, импорт `tl/` всех языков, локали в манифесте, выбор языка и
запасные языки в плеере, `_()`/`!t`/`_preferences.language`.
Миграция уже сконвертированных версий: ID детерминированно выводятся
из текущего IR.
2. **Перевод в библиотеке:** главная версия, черновик и публикация, права,
редактор перевода, прогресс, экспорт и импорт PO/CSV.
3. **Удобство:** контекст сцены, «проиграть отсюда», глоссарий,
рецензирование, шрифты и ассеты локалей, машинный предперевод.
4. **Моды:** редактор скриптов, замена ассетов, слияние и перенос модов.
+1412 -1357
View File
File diff suppressed because it is too large. Load diff
+94
View File
@@ -0,0 +1,94 @@
// Game localisation: per-language line tables (by tid) and a strings table (by source text).
import type { GameData, Manifest } from "./types";
export interface LocaleInfo {
name: string;
renpy: string;
modules: Record<string, string>;
strings: string;
lines: number;
progress: number;
}
interface Entry {
t: string;
state?: string;
}
const PREF_KEY = "pgvn:lang";
export class Translator {
constructor(
public readonly lang: string, // BCP-47; equals sourceLang when untranslated
public readonly renpyName: string | null,
private lines: Map<string, string> = new Map(),
private strings: Map<string, string> = new Map(),
) {}
static source(game: GameData) {
return new Translator(game.sourceLang ?? "und", null);
}
/** Dialogue line by translation id, falling back to the source text. */
line(tid: string | undefined, source: string): string {
return (tid && this.lines.get(tid)) || source;
}
/** Names, menu choices, `_()` strings: looked up by their source text. */
str(source: string): string {
return this.strings.get(source) ?? source;
}
}
export function preferredLanguage(game: GameData): string {
const available = [game.sourceLang ?? "und", ...Object.keys(game.locales ?? {})];
let saved: string | null = null;
try {
saved = localStorage.getItem(PREF_KEY);
} catch {
/* storage unavailable */
}
if (saved && available.includes(saved)) return saved;
// browser languages, matched exactly or by primary subtag (ru-RU -> ru)
for (const nav of navigator.languages ?? [navigator.language]) {
const hit = available.find((a) => a === nav) ?? available.find((a) => a.split("-")[0] === nav.split("-")[0]);
if (hit) return hit;
}
return game.sourceLang ?? "und";
}
export function rememberLanguage(lang: string) {
try {
localStorage.setItem(PREF_KEY, lang);
} catch {
/* storage unavailable */
}
}
export async function loadTranslator(game: GameData, manifest: Manifest, lang: string): Promise<Translator> {
const info = game.locales?.[lang];
if (!info) return Translator.source(game);
const blob = (path: string) => {
const f = manifest.files[path];
if (!f) throw new Error(`в версии нет файла ${path}`);
return fetch(`/blobs/${f.hash}`).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${path}`);
return r.json() as Promise<Record<string, Entry>>;
});
};
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
const lines = new Map<string, string>();
for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t);
return new Translator(lang, info.renpy, lines,
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])));
}
export function languageName(game: GameData, lang: string): string {
if (lang === game.sourceLang) {
const known: Record<string, string> = { en: "English", ru: "Русский", fr: "Français", de: "Deutsch",
es: "Español", ja: "日本語" };
return `${known[lang] ?? lang} (оригинал)`;
}
return game.locales?.[lang]?.name ?? lang;
}
+8 -2
View File
@@ -1,5 +1,6 @@
import "./player.css";
import { Engine, type SaveApi } from "./engine";
import { loadTranslator, preferredLanguage } from "./i18n";
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
async function getJSON<T>(url: string): Promise<T> {
@@ -44,9 +45,14 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json"));
onProgress(`Загрузка скриптов (${game.scripts.length})`);
const modules = await Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s))));
const lang = preferredLanguage(game);
const [modules, tr] = await Promise.all([
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
loadTranslator(game, manifest, lang),
]);
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit);
`pgvn:persistent:${versionId}`, onExit, tr,
(code) => loadTranslator(game, manifest, code));
engine.start();
return engine;
}
+11
View File
@@ -193,3 +193,14 @@
}
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
.pg-main-panel button { text-align: left; }
.pg-select {
font: inherit;
font-size: 0.85em;
color: #fff;
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 0.3em;
padding: 0.3em 0.6em;
}
.pg-select option { background: #151827; }
.pg-lang { margin-top: 0.6em; }
+366 -366
View File
@@ -1,366 +1,366 @@
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
// The stage has the game's native resolution and is scaled with a CSS transform.
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = {
left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 },
right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 },
center: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
default: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
reset: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
truecenter: { xpos: 0.5, xanchor: 0.5, ypos: 0.5, yanchor: 0.5 },
topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 },
topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 },
top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 },
offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 },
offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 },
};
export interface ResolvedImage {
def: ImageDef;
name: string[];
}
export interface RendererHost {
url(file: string): string | null;
resolveRef(name: string): ImageDef | null;
evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null;
text(e: any): string;
}
interface Entry extends ShownImage {
el: HTMLElement;
}
export class Renderer {
readonly stage: HTMLDivElement;
readonly master: HTMLDivElement;
private overlay: HTMLDivElement;
private shown = new Map<string, Entry>();
private committed: HTMLElement | null = null;
fast = false;
constructor(parent: HTMLElement, public width: number, public height: number,
private host: RendererHost) {
this.stage = document.createElement("div");
this.stage.className = "pg-stage";
this.stage.style.width = `${width}px`;
this.stage.style.height = `${height}px`;
this.master = document.createElement("div");
this.master.className = "pg-layer";
this.overlay = document.createElement("div");
this.overlay.className = "pg-layer pg-transition";
this.stage.append(this.master, this.overlay);
parent.append(this.stage);
}
fit(container: HTMLElement) {
const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
}
// --- scene state ---------------------------------------------------------
scene() {
for (const e of this.shown.values()) e.el.remove();
this.shown.clear();
}
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
const prev = this.shown.get(tag);
const atSpecs = at.length ? at : prev?.at ?? [];
const el = this.build(img.def);
el.classList.add("pg-image");
this.place(el, img.def, atSpecs);
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
if (prev) {
prev.el.replaceWith(el);
} else {
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
this.master.insertBefore(el, behind ?? later ?? null);
}
this.shown.set(tag, entry);
}
hide(tag: string) {
const e = this.shown.get(tag);
if (!e) return;
e.el.remove();
this.shown.delete(tag);
}
isShown(tag: string) {
return this.shown.has(tag);
}
attrsOf(tag: string): string[] | null {
return this.shown.get(tag)?.name ?? null;
}
snapshot(): ShownImage[] {
return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
}
// --- displayables ------------------------------------------------------------
private build(def: ImageDef, depth = 0): HTMLElement {
if (depth > 8) return document.createElement("div");
if (def.ref) {
const ref = this.host.resolveRef(def.ref);
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
}
if (def.switch) {
const hit = def.switch.find(([c]) => this.host.evalCond(c));
if (hit) return this.build(hit[1], depth + 1);
}
if (def.color) {
const d = document.createElement("div");
d.style.background = def.color;
d.style.width = `${this.width}px`;
d.style.height = `${this.height}px`;
return d;
}
if (def.composite) {
const d = document.createElement("div");
d.style.position = "relative";
if (def.size) {
d.style.width = `${def.size[0]}px`;
d.style.height = `${def.size[1]}px`;
}
for (const layer of def.composite) {
const child = this.build(layer.img, depth + 1);
child.style.position = "absolute";
child.style.left = `${layer.pos[0]}px`;
child.style.top = `${layer.pos[1]}px`;
d.append(child);
}
return d;
}
if (def.movie) {
const v = document.createElement("video");
const src = this.host.url(def.movie);
if (src) v.src = src;
v.autoplay = true;
v.loop = true;
v.muted = true;
v.playsInline = true;
return v;
}
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!;
return d;
}
if (def.frames?.length) return this.animated(def, depth);
if (def.layered) return this.layered(def, depth);
if (def.file) {
const img = document.createElement("img");
img.draggable = false;
img.decoding = "async";
const src = this.host.url(def.file);
if (src) img.src = src;
else console.warn("[pgvn] нет файла", def.file);
if (def.props?.size) {
img.style.width = `${def.props.size[0]}px`;
img.style.height = `${def.props.size[1]}px`;
}
return img;
}
return document.createElement("div");
}
/** Frame-by-frame ATL animation: swaps children on timers while attached. */
private animated(def: ImageDef, depth: number): HTMLElement {
const box = document.createElement("div");
box.style.position = "relative";
const frames = def.frames!;
let i = 0;
let started = false;
box.append(this.build(frames[0].img, depth + 1));
const tick = () => {
if (box.isConnected) started = true;
else if (started) return;
const d = frames[i].d ?? 0;
const last = i === frames.length - 1;
if (!d || (last && !def.repeat)) return;
window.setTimeout(() => {
if (started && !box.isConnected) return;
i = last ? 0 : i + 1;
box.replaceChildren(this.build(frames[i].img, depth + 1));
tick();
}, d * 1000);
};
tick();
return box;
}
/** layeredimage: stack the layers selected by the image's attributes. */
private layered(def: ImageDef, depth: number): HTMLElement {
const box = document.createElement("div");
box.style.position = "relative";
const sel = new Set(def.selected ?? []);
const groupsSelected = new Set(
def.layered!.filter((l) => l.attr && sel.has(l.attr) && l.group).map((l) => l.group));
const imgs: ImageDef[] = [];
for (const layer of def.layered!) {
if (layer.always) imgs.push(layer.always);
else if (layer.cond) {
const hit = layer.cond.find(([c]) => c === null || this.host.evalCond(c));
if (hit) imgs.push(hit[1]);
} else if (layer.attr !== undefined) {
const on = sel.has(layer.attr) || (!!layer.default && !(layer.group && groupsSelected.has(layer.group)));
if (!on || !layer.img || layer.null) continue;
if (layer.if_any && !layer.if_any.some((a) => sel.has(a))) continue;
if (layer.if_all && !layer.if_all.every((a) => sel.has(a))) continue;
if (layer.if_not && layer.if_not.some((a) => sel.has(a))) continue;
imgs.push(layer.img);
}
}
imgs.forEach((img, n) => {
const child = this.build(img, depth + 1);
if (n > 0) {
child.style.position = "absolute";
child.style.left = "0";
child.style.top = "0";
}
box.append(child);
});
return box;
}
camera(at: AtSpec[]) {
const p: Record<string, any> = {};
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
const zoom = p.zoom ?? 1;
this.master.style.transformOrigin = "50% 50%";
this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
this.cameraAt = at;
}
cameraAt: AtSpec[] = [];
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
for (const spec of [...(def.at ?? []), ...at]) {
if (spec.effect === "shake") {
const dist = spec.dist ?? 20;
el.animate([0, dist, -dist, dist * 0.6, -dist * 0.6, 0].map((x) => ({ translate: `${x}px 0` })),
{ duration: (spec.d ?? 0.5) * 1000 });
continue;
}
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {});
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
if (spec.props) Object.assign(p, spec.props);
}
normalize(p);
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
const ax = anchor(p.xanchor);
const ay = anchor(p.yanchor);
el.style.transformOrigin = `${ax} ${ay}`;
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
// translate(-%) must use the element box, so negative percentages of px anchors are written as is
if (ax.endsWith("px") || ay.endsWith("px"))
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (p.filter) el.style.filter = `${p.filter}(1)`;
}
// --- transitions ---------------------------------------------------------------
/** Remember the current picture as the starting point of the next transition. */
commit() {
this.committed = this.master.cloneNode(true) as HTMLElement;
}
async transition(t: Transition | null | undefined): Promise<void> {
const before = this.committed;
this.commit();
if (!t || !before || this.fast) return;
if (t.t === "punch") return this.punch(t);
const old = before;
old.classList.add("pg-old");
this.overlay.append(old);
try {
if (t.t === "fade") {
const color = document.createElement("div");
color.className = "pg-fill";
color.style.background = t.color ?? "#000";
color.style.opacity = "0";
this.overlay.append(color);
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5);
old.remove();
if (t.hold) await sleep(t.hold);
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5);
color.remove();
} else if (t.t === "wipe") {
const dir = t.dir ?? "right";
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)",
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)";
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1);
} else if (t.t === "slide") {
const dir = t.dir ?? "left";
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir];
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 },
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
} else {
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5);
}
} finally {
old.remove();
}
}
private async punch(t: Transition) {
const axis = t.dir === "h" ? "X" : "Y";
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
t.d ?? 0.3);
}
preload(def: ImageDef | null) {
if (def?.file) {
const src = this.host.url(def.file);
if (src) new Image().src = src;
}
}
}
function normalize(p: Record<string, any>) {
if (p.align) [p.xalign, p.yalign] = p.align;
if (p.pos) [p.xpos, p.ypos] = p.pos;
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor;
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign;
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign;
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5];
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5];
p.xanchor = asFloat(p.xanchor);
p.yanchor = asFloat(p.yanchor);
p.xpos = asFloat(p.xpos);
p.ypos = asFloat(p.ypos);
}
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction.
function asFloat(v: any): number {
return typeof v === "number" ? v : 0;
}
export function sleep(seconds: number) {
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
}
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> {
return new Promise((resolve) => {
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
a.onfinish = () => resolve();
a.oncancel = () => resolve();
});
}
// DOM renderer for the image layer: one absolutely positioned element per shown tag.
// The stage has the game's native resolution and is scaled with a CSS transform.
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = {
left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 },
right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 },
center: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
default: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
reset: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
truecenter: { xpos: 0.5, xanchor: 0.5, ypos: 0.5, yanchor: 0.5 },
topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 },
topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 },
top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 },
offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 },
offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 },
};
export interface ResolvedImage {
def: ImageDef;
name: string[];
}
export interface RendererHost {
url(file: string): string | null;
resolveRef(name: string): ImageDef | null;
evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null;
text(e: any): string;
}
interface Entry extends ShownImage {
el: HTMLElement;
}
export class Renderer {
readonly stage: HTMLDivElement;
readonly master: HTMLDivElement;
private overlay: HTMLDivElement;
private shown = new Map<string, Entry>();
private committed: HTMLElement | null = null;
fast = false;
constructor(parent: HTMLElement, public width: number, public height: number,
private host: RendererHost) {
this.stage = document.createElement("div");
this.stage.className = "pg-stage";
this.stage.style.width = `${width}px`;
this.stage.style.height = `${height}px`;
this.master = document.createElement("div");
this.master.className = "pg-layer";
this.overlay = document.createElement("div");
this.overlay.className = "pg-layer pg-transition";
this.stage.append(this.master, this.overlay);
parent.append(this.stage);
}
fit(container: HTMLElement) {
const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
}
// --- scene state ---------------------------------------------------------
scene() {
for (const e of this.shown.values()) e.el.remove();
this.shown.clear();
}
show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
const prev = this.shown.get(tag);
const atSpecs = at.length ? at : prev?.at ?? [];
const el = this.build(img.def);
el.classList.add("pg-image");
this.place(el, img.def, atSpecs);
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
if (prev) {
prev.el.replaceWith(el);
} else {
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
this.master.insertBefore(el, behind ?? later ?? null);
}
this.shown.set(tag, entry);
}
hide(tag: string) {
const e = this.shown.get(tag);
if (!e) return;
e.el.remove();
this.shown.delete(tag);
}
isShown(tag: string) {
return this.shown.has(tag);
}
attrsOf(tag: string): string[] | null {
return this.shown.get(tag)?.name ?? null;
}
snapshot(): ShownImage[] {
return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
}
// --- displayables ------------------------------------------------------------
private build(def: ImageDef, depth = 0): HTMLElement {
if (depth > 8) return document.createElement("div");
if (def.ref) {
const ref = this.host.resolveRef(def.ref);
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1);
}
if (def.switch) {
const hit = def.switch.find(([c]) => this.host.evalCond(c));
if (hit) return this.build(hit[1], depth + 1);
}
if (def.color) {
const d = document.createElement("div");
d.style.background = def.color;
d.style.width = `${this.width}px`;
d.style.height = `${this.height}px`;
return d;
}
if (def.composite) {
const d = document.createElement("div");
d.style.position = "relative";
if (def.size) {
d.style.width = `${def.size[0]}px`;
d.style.height = `${def.size[1]}px`;
}
for (const layer of def.composite) {
const child = this.build(layer.img, depth + 1);
child.style.position = "absolute";
child.style.left = `${layer.pos[0]}px`;
child.style.top = `${layer.pos[1]}px`;
d.append(child);
}
return d;
}
if (def.movie) {
const v = document.createElement("video");
const src = this.host.url(def.movie);
if (src) v.src = src;
v.autoplay = true;
v.loop = true;
v.muted = true;
v.playsInline = true;
return v;
}
if (def.text !== undefined || def.textExpr) {
const d = document.createElement("div");
d.className = "pg-show-text";
d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text });
return d;
}
if (def.frames?.length) return this.animated(def, depth);
if (def.layered) return this.layered(def, depth);
if (def.file) {
const img = document.createElement("img");
img.draggable = false;
img.decoding = "async";
const src = this.host.url(def.file);
if (src) img.src = src;
else console.warn("[pgvn] нет файла", def.file);
if (def.props?.size) {
img.style.width = `${def.props.size[0]}px`;
img.style.height = `${def.props.size[1]}px`;
}
return img;
}
return document.createElement("div");
}
/** Frame-by-frame ATL animation: swaps children on timers while attached. */
private animated(def: ImageDef, depth: number): HTMLElement {
const box = document.createElement("div");
box.style.position = "relative";
const frames = def.frames!;
let i = 0;
let started = false;
box.append(this.build(frames[0].img, depth + 1));
const tick = () => {
if (box.isConnected) started = true;
else if (started) return;
const d = frames[i].d ?? 0;
const last = i === frames.length - 1;
if (!d || (last && !def.repeat)) return;
window.setTimeout(() => {
if (started && !box.isConnected) return;
i = last ? 0 : i + 1;
box.replaceChildren(this.build(frames[i].img, depth + 1));
tick();
}, d * 1000);
};
tick();
return box;
}
/** layeredimage: stack the layers selected by the image's attributes. */
private layered(def: ImageDef, depth: number): HTMLElement {
const box = document.createElement("div");
box.style.position = "relative";
const sel = new Set(def.selected ?? []);
const groupsSelected = new Set(
def.layered!.filter((l) => l.attr && sel.has(l.attr) && l.group).map((l) => l.group));
const imgs: ImageDef[] = [];
for (const layer of def.layered!) {
if (layer.always) imgs.push(layer.always);
else if (layer.cond) {
const hit = layer.cond.find(([c]) => c === null || this.host.evalCond(c));
if (hit) imgs.push(hit[1]);
} else if (layer.attr !== undefined) {
const on = sel.has(layer.attr) || (!!layer.default && !(layer.group && groupsSelected.has(layer.group)));
if (!on || !layer.img || layer.null) continue;
if (layer.if_any && !layer.if_any.some((a) => sel.has(a))) continue;
if (layer.if_all && !layer.if_all.every((a) => sel.has(a))) continue;
if (layer.if_not && layer.if_not.some((a) => sel.has(a))) continue;
imgs.push(layer.img);
}
}
imgs.forEach((img, n) => {
const child = this.build(img, depth + 1);
if (n > 0) {
child.style.position = "absolute";
child.style.left = "0";
child.style.top = "0";
}
box.append(child);
});
return box;
}
camera(at: AtSpec[]) {
const p: Record<string, any> = {};
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
const zoom = p.zoom ?? 1;
this.master.style.transformOrigin = "50% 50%";
this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
this.cameraAt = at;
}
cameraAt: AtSpec[] = [];
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
for (const spec of [...(def.at ?? []), ...at]) {
if (spec.effect === "shake") {
const dist = spec.dist ?? 20;
el.animate([0, dist, -dist, dist * 0.6, -dist * 0.6, 0].map((x) => ({ translate: `${x}px 0` })),
{ duration: (spec.d ?? 0.5) * 1000 });
continue;
}
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {});
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
if (spec.props) Object.assign(p, spec.props);
}
normalize(p);
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
const ax = anchor(p.xanchor);
const ay = anchor(p.yanchor);
el.style.transformOrigin = `${ax} ${ay}`;
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
// translate(-%) must use the element box, so negative percentages of px anchors are written as is
if (ax.endsWith("px") || ay.endsWith("px"))
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (p.filter) el.style.filter = `${p.filter}(1)`;
}
// --- transitions ---------------------------------------------------------------
/** Remember the current picture as the starting point of the next transition. */
commit() {
this.committed = this.master.cloneNode(true) as HTMLElement;
}
async transition(t: Transition | null | undefined): Promise<void> {
const before = this.committed;
this.commit();
if (!t || !before || this.fast) return;
if (t.t === "punch") return this.punch(t);
const old = before;
old.classList.add("pg-old");
this.overlay.append(old);
try {
if (t.t === "fade") {
const color = document.createElement("div");
color.className = "pg-fill";
color.style.background = t.color ?? "#000";
color.style.opacity = "0";
this.overlay.append(color);
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5);
old.remove();
if (t.hold) await sleep(t.hold);
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5);
color.remove();
} else if (t.t === "wipe") {
const dir = t.dir ?? "right";
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)",
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)";
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1);
} else if (t.t === "slide") {
const dir = t.dir ?? "left";
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir];
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 },
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
} else {
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5);
}
} finally {
old.remove();
}
}
private async punch(t: Transition) {
const axis = t.dir === "h" ? "X" : "Y";
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
t.d ?? 0.3);
}
preload(def: ImageDef | null) {
if (def?.file) {
const src = this.host.url(def.file);
if (src) new Image().src = src;
}
}
}
function normalize(p: Record<string, any>) {
if (p.align) [p.xalign, p.yalign] = p.align;
if (p.pos) [p.xpos, p.ypos] = p.pos;
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor;
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign;
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign;
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5];
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5];
p.xanchor = asFloat(p.xanchor);
p.yanchor = asFloat(p.yanchor);
p.xpos = asFloat(p.xpos);
p.ypos = asFloat(p.ypos);
}
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction.
function asFloat(v: any): number {
return typeof v === "number" ? v : 0;
}
export function sleep(seconds: number) {
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
}
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> {
return new Promise((resolve) => {
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
a.onfinish = () => resolve();
a.oncancel = () => resolve();
});
}
+3 -1
View File
@@ -22,7 +22,8 @@ export interface ParsedText {
length: number; // number of visible characters
}
export function interpolate(text: string, lookup: (expr: string) => any): string {
export function interpolate(text: string, lookup: (expr: string) => any,
translate: (s: string) => string = (s) => s): string {
return text.replace(/\[\[|\[([^\[\]]+)\]/g, (m, expr: string | undefined) => {
if (m === "[[") return "[";
let src = expr!.trim();
@@ -33,6 +34,7 @@ export function interpolate(text: string, lookup: (expr: string) => any): string
src = src.slice(0, bang);
}
let v = pyStr(lookup(src));
if (flags.includes("t")) v = translate(v);
if (flags.includes("u")) v = v.toUpperCase();
if (flags.includes("l")) v = v.toLowerCase();
if (flags.includes("c")) v = v.charAt(0).toUpperCase() + v.slice(1);
+145 -143
View File
@@ -1,143 +1,145 @@
// PGVN v1 — the format produced by the backend converter (see docs/ARCHITECTURE.md).
export type Expr = { k: string; [key: string]: any };
export type PyStmt = { op: string; [key: string]: any };
export interface Transition {
t: "dissolve" | "fade" | "move" | "punch" | "wipe" | "slide" | "ref";
d?: number;
out?: number;
hold?: number;
in?: number;
color?: string;
dir?: string;
name?: string;
}
export interface ImageDef {
file?: string;
color?: string;
ref?: string;
movie?: string;
expr?: Expr;
switch?: [Expr, ImageDef][];
composite?: { pos: [number, number]; img: ImageDef }[];
size?: [number, number] | null;
text?: string;
textExpr?: Expr;
props?: Record<string, any>;
frames?: { img: ImageDef; d?: number }[];
repeat?: boolean;
layered?: Layer[];
at?: AtSpec[];
selected?: string[]; // attributes chosen for a layered image (set by the engine)
}
export interface Layer {
always?: ImageDef;
attr?: string;
group?: string | null;
default?: boolean;
null?: boolean;
img?: ImageDef;
if_any?: string[];
if_all?: string[];
if_not?: string[];
cond?: [Expr | null, ImageDef][];
autoGroup?: string;
}
export interface AtSpec {
pos?: string;
name?: string;
props?: Record<string, any>;
effect?: "shake";
d?: number;
dist?: number;
}
export interface CharacterDef {
name?: string | null;
nameExpr?: Expr;
color?: string;
whatColor?: string;
whatPrefix?: string;
whatSuffix?: string;
whoPrefix?: string;
whoSuffix?: string;
image?: string;
nvl?: boolean;
whatItalic?: boolean;
whatBold?: boolean;
}
export type Node = { op: string; [key: string]: any };
export interface Label {
name: string;
body: Node[];
params?: [string, Expr | null][];
}
export interface Anchor {
label: string;
path: number[];
params?: [string, Expr | null][] | null;
}
export interface ScriptModule {
file: string;
labels: Label[];
anchors?: Record<string, Anchor>;
}
export interface GameData {
format: string;
title: string | null;
resolution: [number, number];
entry: string;
scripts: string[];
characters: Record<string, CharacterDef>;
images: Record<string, ImageDef>;
transforms: Record<string, Record<string, any>>;
transitions: Record<string, Transition | null>;
audio: Record<string, string>;
channels?: Record<string, { mixer: string; loop: boolean }>;
files: Record<string, string>;
defaults: [string, Expr][];
init: PyStmt[];
ui: Record<string, any>;
}
export interface Manifest {
format: string;
files: Record<string, { hash: string; size: number }>;
}
export interface Frame {
label: string;
path: number[];
// values of label parameters shadowed by a call, restored on return
saved?: Record<string, { had: boolean; value: any }>;
}
export interface ShownImage {
tag: string;
name: string[];
at: AtSpec[];
zorder: number;
layer?: string;
}
export interface SaveData {
v: 1;
cur: Frame;
stack: Frame[];
store: any;
scene: ShownImage[];
music: { file: string; loop: boolean } | null;
window: boolean;
camera?: AtSpec[];
menuSeen?: string[];
text?: string;
}
// PGVN v1 — the format produced by the backend converter (see docs/ARCHITECTURE.md).
export type Expr = { k: string; [key: string]: any };
export type PyStmt = { op: string; [key: string]: any };
export interface Transition {
t: "dissolve" | "fade" | "move" | "punch" | "wipe" | "slide" | "ref";
d?: number;
out?: number;
hold?: number;
in?: number;
color?: string;
dir?: string;
name?: string;
}
export interface ImageDef {
file?: string;
color?: string;
ref?: string;
movie?: string;
expr?: Expr;
switch?: [Expr, ImageDef][];
composite?: { pos: [number, number]; img: ImageDef }[];
size?: [number, number] | null;
text?: string;
textExpr?: Expr;
props?: Record<string, any>;
frames?: { img: ImageDef; d?: number }[];
repeat?: boolean;
layered?: Layer[];
at?: AtSpec[];
selected?: string[]; // attributes chosen for a layered image (set by the engine)
}
export interface Layer {
always?: ImageDef;
attr?: string;
group?: string | null;
default?: boolean;
null?: boolean;
img?: ImageDef;
if_any?: string[];
if_all?: string[];
if_not?: string[];
cond?: [Expr | null, ImageDef][];
autoGroup?: string;
}
export interface AtSpec {
pos?: string;
name?: string;
props?: Record<string, any>;
effect?: "shake";
d?: number;
dist?: number;
}
export interface CharacterDef {
name?: string | null;
nameExpr?: Expr;
color?: string;
whatColor?: string;
whatPrefix?: string;
whatSuffix?: string;
whoPrefix?: string;
whoSuffix?: string;
image?: string;
nvl?: boolean;
whatItalic?: boolean;
whatBold?: boolean;
}
export type Node = { op: string; [key: string]: any };
export interface Label {
name: string;
body: Node[];
params?: [string, Expr | null][];
}
export interface Anchor {
label: string;
path: number[];
params?: [string, Expr | null][] | null;
}
export interface ScriptModule {
file: string;
labels: Label[];
anchors?: Record<string, Anchor>;
}
export interface GameData {
format: string;
title: string | null;
resolution: [number, number];
entry: string;
scripts: string[];
characters: Record<string, CharacterDef>;
images: Record<string, ImageDef>;
transforms: Record<string, Record<string, any>>;
transitions: Record<string, Transition | null>;
audio: Record<string, string>;
channels?: Record<string, { mixer: string; loop: boolean }>;
files: Record<string, string>;
defaults: [string, Expr][];
init: PyStmt[];
ui: Record<string, any>;
sourceLang?: string;
locales?: Record<string, import("./i18n").LocaleInfo>;
}
export interface Manifest {
format: string;
files: Record<string, { hash: string; size: number }>;
}
export interface Frame {
label: string;
path: number[];
// values of label parameters shadowed by a call, restored on return
saved?: Record<string, { had: boolean; value: any }>;
}
export interface ShownImage {
tag: string;
name: string[];
at: AtSpec[];
zorder: number;
layer?: string;
}
export interface SaveData {
v: 1;
cur: Frame;
stack: Frame[];
store: any;
scene: ShownImage[];
music: { file: string; loop: boolean } | null;
window: boolean;
camera?: AtSpec[];
menuSeen?: string[];
text?: string;
}