Localization stage 1: stable IDs, import of tl/ translations, language picker
- every script node gets a permanent id, say nodes a Ren'Py-style tid - all languages from the game's tl/ are imported at conversion: dialogue blocks matched by their source-location comment or by label + original text, `translate strings` become a per-language strings table - source language from config.language or detected from the dialogue - manifest stores locales/<lang>/<module>.json and strings.json; game.json lists sourceLang and available locales with progress - player: language picker in main menu and settings, fallback to source, __(), [var!t] and _preferences.language - parser: `default` with init priority, and one bad statement no longer drops the whole file - docs/LOCALIZATION.md: design for translations, publishing and mods Line endings of several converter/player files go back to LF: an earlier commit accidentally stored them as CRLF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5d9ddb6376
commit
e1d7b29f77
16 files changed
+4280
-3530
No files matched your search
+155
-144
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
+1229
-1226
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
# TODO: Translation updated at 2026-10-25 12:00
|
||||
|
||||
# game/script.rpy:29
|
||||
translate english start_1a2b3c4d:
|
||||
|
||||
# "Добро пожаловать в {b}тестовую{/b} новеллу."
|
||||
"Welcome to the {b}test{/b} novel."
|
||||
|
||||
# game/script.rpy:32
|
||||
translate english start_5e6f7a8b:
|
||||
|
||||
# e "Привет! Как тебя зовут?"
|
||||
e "Hi! What's your name?"
|
||||
|
||||
# matched by label + original text, the location comment is stale
|
||||
translate english walk_deadbeef:
|
||||
|
||||
# e sad "На улице дождь..."
|
||||
e sad "It's raining outside..."
|
||||
|
||||
translate english strings:
|
||||
|
||||
# game/script.rpy:2
|
||||
old "Эйлин"
|
||||
new "Eileen"
|
||||
|
||||
old "Пойдём гулять"
|
||||
new "Let's go for a walk"
|
||||
@@ -59,6 +59,8 @@ def build(path: Path) -> Path:
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("SampleGame-1.0-pc/game/archive.rpa", rpa_path.read_bytes())
|
||||
zf.writestr("SampleGame-1.0-pc/game/options.rpy", options)
|
||||
zf.writestr("SampleGame-1.0-pc/game/tl/english/script.rpy",
|
||||
(FIXTURES / "tl" / "english" / "script.rpy").read_bytes())
|
||||
zf.writestr("SampleGame-1.0-pc/game/audio/theme.wav", _wav())
|
||||
zf.writestr("SampleGame-1.0-pc/game/gui/main_menu.png",
|
||||
_png((1280, 720), (30, 30, 50), "Main menu"))
|
||||
|
||||
@@ -36,7 +36,7 @@ def test_say_variants():
|
||||
d = parse('label start:\n "narration"\n e happy "hi" with dissolve\n'
|
||||
' "Bob" "named"\n centered "c"\n')
|
||||
body = d.modules["t.rpy"][0]["body"]
|
||||
assert body[0] == {"op": "say", "what": "narration"}
|
||||
assert {k: v for k, v in body[0].items() if k != "_ln"} == {"op": "say", "what": "narration"}
|
||||
assert body[1]["who"] == "e" and body[1]["attrs"] == ["happy"] and body[1]["with"]["t"] == "dissolve"
|
||||
assert body[2]["whoStr"] == "Bob"
|
||||
assert body[3]["who"] == "centered"
|
||||
@@ -80,6 +80,17 @@ def test_full_pipeline(data_dir, tmp_path):
|
||||
if shutil.which("ffmpeg"):
|
||||
assert "audio/theme.ogg" in files
|
||||
|
||||
# every node has an id, says have tids; tl/english imported into our tables
|
||||
assert all("id" in n for n in script["labels"][0]["body"])
|
||||
assert game["sourceLang"] == "ru"
|
||||
en = game["locales"]["en"]
|
||||
assert en["renpy"] == "english" and en["lines"] == 3
|
||||
lines = json.loads(read_bytes(files[en["modules"]["script/script.json"]]["hash"]))
|
||||
first_say = next(n for n in script["labels"][0]["body"] if n["op"] == "say")
|
||||
assert lines[first_say["tid"]]["t"] == "Welcome to the {b}test{/b} novel."
|
||||
strings = json.loads(read_bytes(files[en["strings"]]["hash"]))
|
||||
assert strings["Эйлин"]["t"] == "Eileen"
|
||||
|
||||
|
||||
def test_nested_labels_and_named_menus_become_anchors():
|
||||
from app.converter.pipeline import anchors
|
||||
|
||||
@@ -105,7 +105,10 @@ LOCKED`), Redis не нужен.
|
||||
редактировать), состояние плеера — стек `{label, path}` где `path` — индексы
|
||||
внутри вложенных блоков, так что сохранения стабильны и компактны.
|
||||
|
||||
Узлы (`op`): `say`, `scene`, `show`, `hide`, `with`, `play`, `stop`, `queue`,
|
||||
У каждого узла есть постоянный `id`, у реплик — ещё `tid` для перевода.
|
||||
Переводы лежат отдельно, в `locales/<язык>/…` (см. [LOCALIZATION.md](LOCALIZATION.md)).
|
||||
|
||||
Узлы (`op`): `label` (точка входа внутри блока), `camera`, `say`, `scene`, `show`, `hide`, `with`, `play`, `stop`, `queue`,
|
||||
`pause`, `menu`, `if`, `while`, `jump`, `call`, `return`, `exec`, `window`,
|
||||
`noop`. Выражения: `const`, `name`, `attr`, `sub`, `bin`, `bool`, `cmp`,
|
||||
`unary`, `if`, `list`, `tuple`, `dict`, `call`, `fmt`.
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
# Локализация и моды — проект
|
||||
|
||||
Цель: переводить и модифицировать игры прямо в библиотеке, нативно и удобно.
|
||||
Переводы из самих игр импортируются при конвертации, дальше живут только в
|
||||
нашем формате.
|
||||
|
||||
## Почему это нужно решить сейчас
|
||||
|
||||
Исходники игры удаляются после конвертации. Всё, что не вытащено в момент
|
||||
конвертации (готовые переводы из `tl/`, привязка реплик к их месту в
|
||||
исходнике), потом взять будет неоткуда. Поэтому стабильные идентификаторы
|
||||
и импорт переводов должны появиться в конвертере до того, как библиотека
|
||||
наполнится играми.
|
||||
|
||||
## Что есть в играх (по примерам)
|
||||
|
||||
| Игра | Языки в `tl/` | Переведённых реплик |
|
||||
|---|---|---|
|
||||
| That New Teacher | 19 (в т.ч. russian) | ~350 000 блоков |
|
||||
| Twisted Thoughts | 9; исходник на французском, english — перевод | ~117 000 |
|
||||
| остальные 4 | только исходный | — |
|
||||
|
||||
Формат Ren'Py:
|
||||
|
||||
```
|
||||
# game/Days/dayEight.rpy:248
|
||||
translate russian D8_Clarissa_c7855708:
|
||||
# "You lie on your side, facing away..."
|
||||
"Ты лежишь на боку, отвернувшись..."
|
||||
|
||||
translate russian strings:
|
||||
old "Iris"
|
||||
new "Айрис"
|
||||
```
|
||||
|
||||
Реплики переводятся блоками с идентификатором (`метка_хэш`) и ссылкой на
|
||||
строку оригинала; имена, пункты меню и строки интерфейса — таблицей old/new.
|
||||
Бывает и игровая логика, зависящая от языка (`_preferences.language`).
|
||||
|
||||
## Главная идея: текст отдельно от скрипта, у всего стабильные ID
|
||||
|
||||
1. **ID узлов.** Каждый оператор скрипта получает постоянный `id`. Он
|
||||
присваивается один раз при конвертации и дальше не меняется при правках.
|
||||
На этих ID держатся переводы, сохранения и в будущем слияние модов.
|
||||
2. **ID строк.** Реплики получают `tid` в стиле Ren'Py (`метка_хэш`) и
|
||||
хранят его в скрипте, так что правки текста ID не меняют. Пункты меню,
|
||||
имена, `_()` и `show text` переводятся таблицей строк по исходному тексту,
|
||||
как в Ren'Py.
|
||||
3. **Исходный текст остаётся в скрипте.** Скрипт читается как есть, язык
|
||||
оригинала — это просто «исходная локаль» (у Twisted Thoughts — французский).
|
||||
4. **Переводы — отдельные таблицы** `locales/<язык>/<модуль>.json`:
|
||||
|
||||
```json
|
||||
{ "start_577f33b3": { "t": "Ммм...", "src": "a1b2c3", "state": "translated" } }
|
||||
```
|
||||
|
||||
`src` — хэш исходного текста на момент перевода. Если в моде или новой
|
||||
версии оригинал реплики изменился, перевод помечается «устарел», а не
|
||||
теряется молча. Состояния: `translated`, `fuzzy`/устарел, `machine`,
|
||||
`approved`.
|
||||
5. **Файлы локали режутся по модулям скрипта.** Правка одной реплики создаёт
|
||||
один новый маленький блоб — остальное общее (наш content-addressed store).
|
||||
|
||||
`locales/<язык>/meta.json`: код языка (BCP-47: `ru`), название, авторы,
|
||||
прогресс, шрифт-замена (у многих игр в шрифтах нет кириллицы или CJK),
|
||||
переопределения ассетов для языка: картинки с надписями, озвучка.
|
||||
|
||||
## Импорт переводов из игры
|
||||
|
||||
При конвертации разбираем `tl/<lang>/`:
|
||||
|
||||
* блок `translate <lang> <id>` сопоставляем с нашей репликой по комментарию
|
||||
`# game/file.rpy:строка` (точно и просто), запасной вариант — по ID Ren'Py;
|
||||
* `translate <lang> strings` превращаем в таблицу old→new для имён, меню и `_()`;
|
||||
* языки Ren'Py (`russian`, `spanish_latam`, `None`) переводим в коды `ru`,
|
||||
`es-419` и исходную локаль;
|
||||
* импортируются все языки игры — это только текст, места почти не занимает.
|
||||
|
||||
## Плеер
|
||||
|
||||
* Выбор языка в главном меню и настройках, запоминается для каждого
|
||||
пользователя.
|
||||
* Цепочка запасных языков: `ru → en → исходный`. Непереведённая строка
|
||||
показывается на запасном языке и не ломает игру.
|
||||
* `_()`, флаг `!t` в `[переменных]`, имена персонажей переводятся из таблиц;
|
||||
`_preferences.language` отдаёт выбранный язык, поэтому языковая логика
|
||||
игры (как в Twisted Thoughts) работает.
|
||||
* Шрифт и ассеты подменяются из `meta.json` локали.
|
||||
|
||||
## Версии и публикация
|
||||
|
||||
Сценарий:
|
||||
|
||||
1. Модератор делает форк главной версии: «Перевод RU (черновик)».
|
||||
2. Переводит в редакторе. Форк виден только ему и тем, у кого есть права
|
||||
на рецензию.
|
||||
3. Нажимает «Опубликовать перевод».
|
||||
|
||||
**Публикация переносит локаль, а не подменяет версию.** Если два
|
||||
переводчика параллельно делают RU и DE в двух форках, «сделать форк главным»
|
||||
потеряет работу одного из них. Поэтому публикация создаёт новую главную
|
||||
версию: главная + локаль из форка. Переводы склеиваются по `tid` построчно,
|
||||
это тривиально. Перенос перевода в любую совместимую версию (например, в мод)
|
||||
работает так же — одной кнопкой, по тем же ID.
|
||||
|
||||
Модели:
|
||||
|
||||
* `games.main_version_id` — главная версия (её открывает кнопка «Играть»);
|
||||
* у версии `visibility`: `draft` / `published`, а также `kind`: оригинал,
|
||||
перевод, мод;
|
||||
* новые права: `translations.edit` — переводить в своих форках,
|
||||
`versions.publish` — публиковать, назначать главную.
|
||||
|
||||
## Редактор перевода
|
||||
|
||||
* Таблица строк по сценам и меткам: оригинал | перевод | статус, фильтры
|
||||
«не переведено / устарело / машинное», поиск, прогресс в процентах.
|
||||
* Контекст строки: кто говорит, соседние реплики, фон и спрайты сцены на этом
|
||||
месте, кнопка «проиграть с этой строки» в плеере.
|
||||
* Глоссарий: имена и термины подставляются и проверяются на единообразие.
|
||||
* Экспорт и импорт PO/XLIFF/CSV для внешних инструментов, экспорт обратно
|
||||
в формат Ren'Py `tl/`.
|
||||
* Позже: режим «править прямо в игре» — клик по реплике в плеере;
|
||||
машинный предперевод с пометкой `machine`.
|
||||
|
||||
## Моды
|
||||
|
||||
Те же основы делают моды нативными:
|
||||
|
||||
* скрипт редактируется на уровне меток и операторов с постоянными ID;
|
||||
* мод — форк с изменёнными модулями скрипта и ассетами: в хранилище только
|
||||
отличия;
|
||||
* переводы оригинала автоматически применяются к неизменённым репликам мода,
|
||||
новые реплики мода видны как непереведённые;
|
||||
* так как у всего есть ID и родитель, можно делать трёхстороннее слияние:
|
||||
мод A + мод B, или перенос мода на новую версию оригинала.
|
||||
|
||||
## Принятые решения
|
||||
|
||||
* Публикация перевода — **слияние локали** в новую главную версию, а не
|
||||
подмена главной версии форком.
|
||||
* Из игр импортируются **все языки** `tl/`.
|
||||
* Машинный предперевод — **позже, как подключаемый провайдер**. Сейчас
|
||||
закладывается только состояние строки `machine`.
|
||||
* Начинаем с этапа 1.
|
||||
|
||||
## Состояние
|
||||
|
||||
Этап 1 сделан:
|
||||
|
||||
* у всех узлов скрипта есть `id`, у реплик — `tid`;
|
||||
* при конвертации импортируются все языки из `tl/`. Блок сопоставляется
|
||||
с репликой по комментарию `# game/file.rpy:N`, а если он устарел — по
|
||||
метке и исходному тексту. Отдельные таблицы `strings` тоже импортируются;
|
||||
* исходный язык берётся из `config.language` или определяется по тексту
|
||||
(у Twisted Thoughts — французский);
|
||||
* в манифесте лежат `locales/<код>/<модуль>.json` и `locales/<код>/strings.json`,
|
||||
в `game.json` — `sourceLang` и `locales` с прогрессом;
|
||||
* в плеере есть выбор языка в главном меню и настройках, запасной язык —
|
||||
исходный, работают `__()`, `!t`, `_preferences.language`.
|
||||
|
||||
Проверено на примерах: Twisted Thoughts — 9 языков, по 12 949 реплик,
|
||||
100% сопоставлено. That New Teacher — 18 языков, около 75% реплик: остальное
|
||||
авторы ещё не перевели, а ~750 блоков на язык относятся к уже изменённым
|
||||
английским репликам. Ren'Py их тоже не показывает.
|
||||
|
||||
Уже сконвертированные версии остаются без ID и без переводов, их нужно
|
||||
переимпортировать. Пока в библиотеке только тестовые данные, отдельную
|
||||
миграцию не делаем.
|
||||
|
||||
## Этапы
|
||||
|
||||
1. **Фундамент (сейчас, до наполнения библиотеки):** ID узлов и строк в
|
||||
конвертере, импорт `tl/` всех языков, локали в манифесте, выбор языка и
|
||||
запасные языки в плеере, `_()`/`!t`/`_preferences.language`.
|
||||
Миграция уже сконвертированных версий: ID детерминированно выводятся
|
||||
из текущего IR.
|
||||
2. **Перевод в библиотеке:** главная версия, черновик и публикация, права,
|
||||
редактор перевода, прогресс, экспорт и импорт PO/CSV.
|
||||
3. **Удобство:** контекст сцены, «проиграть отсюда», глоссарий,
|
||||
рецензирование, шрифты и ассеты локалей, машинный предперевод.
|
||||
4. **Моды:** редактор скриптов, замена ассетов, слияние и перенос модов.
|
||||
+1412
-1357
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,94 @@
|
||||
// Game localisation: per-language line tables (by tid) and a strings table (by source text).
|
||||
|
||||
import type { GameData, Manifest } from "./types";
|
||||
|
||||
export interface LocaleInfo {
|
||||
name: string;
|
||||
renpy: string;
|
||||
modules: Record<string, string>;
|
||||
strings: string;
|
||||
lines: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
t: string;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
const PREF_KEY = "pgvn:lang";
|
||||
|
||||
export class Translator {
|
||||
constructor(
|
||||
public readonly lang: string, // BCP-47; equals sourceLang when untranslated
|
||||
public readonly renpyName: string | null,
|
||||
private lines: Map<string, string> = new Map(),
|
||||
private strings: Map<string, string> = new Map(),
|
||||
) {}
|
||||
|
||||
static source(game: GameData) {
|
||||
return new Translator(game.sourceLang ?? "und", null);
|
||||
}
|
||||
|
||||
/** Dialogue line by translation id, falling back to the source text. */
|
||||
line(tid: string | undefined, source: string): string {
|
||||
return (tid && this.lines.get(tid)) || source;
|
||||
}
|
||||
|
||||
/** Names, menu choices, `_()` strings: looked up by their source text. */
|
||||
str(source: string): string {
|
||||
return this.strings.get(source) ?? source;
|
||||
}
|
||||
}
|
||||
|
||||
export function preferredLanguage(game: GameData): string {
|
||||
const available = [game.sourceLang ?? "und", ...Object.keys(game.locales ?? {})];
|
||||
let saved: string | null = null;
|
||||
try {
|
||||
saved = localStorage.getItem(PREF_KEY);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
if (saved && available.includes(saved)) return saved;
|
||||
// browser languages, matched exactly or by primary subtag (ru-RU -> ru)
|
||||
for (const nav of navigator.languages ?? [navigator.language]) {
|
||||
const hit = available.find((a) => a === nav) ?? available.find((a) => a.split("-")[0] === nav.split("-")[0]);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return game.sourceLang ?? "und";
|
||||
}
|
||||
|
||||
export function rememberLanguage(lang: string) {
|
||||
try {
|
||||
localStorage.setItem(PREF_KEY, lang);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadTranslator(game: GameData, manifest: Manifest, lang: string): Promise<Translator> {
|
||||
const info = game.locales?.[lang];
|
||||
if (!info) return Translator.source(game);
|
||||
const blob = (path: string) => {
|
||||
const f = manifest.files[path];
|
||||
if (!f) throw new Error(`в версии нет файла ${path}`);
|
||||
return fetch(`/blobs/${f.hash}`).then((r) => {
|
||||
if (!r.ok) throw new Error(`${r.status} ${path}`);
|
||||
return r.json() as Promise<Record<string, Entry>>;
|
||||
});
|
||||
};
|
||||
const [strings, ...modules] = await Promise.all([blob(info.strings), ...Object.values(info.modules).map(blob)]);
|
||||
const lines = new Map<string, string>();
|
||||
for (const mod of modules) for (const [tid, e] of Object.entries(mod)) lines.set(tid, e.t);
|
||||
return new Translator(lang, info.renpy, lines,
|
||||
new Map(Object.entries(strings).map(([k, e]) => [k, e.t])));
|
||||
}
|
||||
|
||||
export function languageName(game: GameData, lang: string): string {
|
||||
if (lang === game.sourceLang) {
|
||||
const known: Record<string, string> = { en: "English", ru: "Русский", fr: "Français", de: "Deutsch",
|
||||
es: "Español", ja: "日本語" };
|
||||
return `${known[lang] ?? lang} (оригинал)`;
|
||||
}
|
||||
return game.locales?.[lang]?.name ?? lang;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import "./player.css";
|
||||
import { Engine, type SaveApi } from "./engine";
|
||||
import { loadTranslator, preferredLanguage } from "./i18n";
|
||||
import type { GameData, Manifest, SaveData, ScriptModule } from "./types";
|
||||
|
||||
async function getJSON<T>(url: string): Promise<T> {
|
||||
@@ -44,9 +45,14 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
||||
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
|
||||
const game = await getJSON<GameData>(blob(manifest, "game.json"));
|
||||
onProgress(`Загрузка скриптов (${game.scripts.length})`);
|
||||
const modules = await Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s))));
|
||||
const lang = preferredLanguage(game);
|
||||
const [modules, tr] = await Promise.all([
|
||||
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
||||
loadTranslator(game, manifest, lang),
|
||||
]);
|
||||
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
|
||||
`pgvn:persistent:${versionId}`, onExit);
|
||||
`pgvn:persistent:${versionId}`, onExit, tr,
|
||||
(code) => loadTranslator(game, manifest, code));
|
||||
engine.start();
|
||||
return engine;
|
||||
}
|
||||
@@ -193,3 +193,14 @@
|
||||
}
|
||||
.pg-main-panel h1 { margin: 0 0 0.5em; font-size: 1.6em; color: var(--pg-accent); }
|
||||
.pg-main-panel button { text-align: left; }
|
||||
.pg-select {
|
||||
font: inherit;
|
||||
font-size: 0.85em;
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 0.3em;
|
||||
padding: 0.3em 0.6em;
|
||||
}
|
||||
.pg-select option { background: #151827; }
|
||||
.pg-lang { margin-top: 0.6em; }
|
||||
+366
-366
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user