diff --git a/README.md b/README.md index 87057c5..60c35e0 100644 --- a/README.md +++ b/README.md @@ -1,78 +1,102 @@ -# PGames — локальная библиотека визуальных новелл - -Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит -скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM) -и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры -может быть несколько версий (форков) от разных пользователей; версии делят -общие ассеты, поэтому форк почти не занимает места. - -Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). - -## Установка на VM - -```bash -git clone pgames && cd pgames -cp .env.example .env -# обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли -docker compose up -d --build -``` - -Приложение слушает `http://:8080` (порт — `HTTP_PORT` в `.env`). -Caddy на хосте Proxmox: - -``` -games.example.lan { - reverse_proxy 192.168.1.50:8080 -} -``` - -Если в браузере сайт открывается по HTTPS через Caddy, оставьте -`COOKIE_SECURE=true`; при доступе напрямую по HTTP поставьте `false`, иначе -браузер не сохранит сессию. - -## Первый вход - -Логин `admin` / пароль из `ADMIN_PASSWORD` (по умолчанию `admin`). Сразу после -входа нужно задать свой логин и пароль. - -Регистрации нет: пользователей создаёт админ в разделе «Пользователи». -Сервер генерирует пароль и показывает его один раз — передайте его человеку, -при первом входе он задаст свой. Права настраиваются через роли -(«Роли»): `admin`, `moderator`, `player` плюс свои. - -## Данные и бэкап - -* `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256); -* том `db` — PostgreSQL (пользователи, игры, версии, сохранения). - -Для бэкапа достаточно `pg_dump` и копии `data/blobs`. - -## Разработка - -На Windows тестовый стенд поднимается одной командой: `start-test.bat`. -Он запускает отдельный compose-проект `pgames-test` на http://localhost:8090 -(свой `.env.test`, данные в `data-test\`), генерирует тестовую игру -`data-test\sample.zip` и открывает браузер. `start-test.bat stop` — остановить, -`reset` — удалить стенд вместе с данными, `logs` — логи бэкенда и воркера. - -```bash -cd backend && pip install -r requirements.txt pytest && python -m pytest -cd frontend && npm install && npm run dev # проксирует /api на :8000 -python -m tests.make_sample sample.zip # тестовая игра (из backend/) -``` - -## Что поддерживает конвертер Ren'Py - -`label`, реплики (в т.ч. атрибуты, `extend`, `centered`, NVL), `scene/show/hide` -с `at`/`as`/`behind`/`zorder`/`with`, переходы (`dissolve`, `fade`, -`Fade(...)`, `vpunch`, wipe/slide…), `menu` с условиями и `set`, `if/elif/else`, -`while`, `jump/call/return` (включая `expression`), `$`/`python:`/`init python:` -(подмножество Python: переменные, выражения, списки/словари, циклы), -`define/default`, `Character(...)`, `image` (файлы, цвета, `Transform`, `im.*`, -`Composite`, `ConditionSwitch`, простые ATL), `transform` со статическими -свойствами, `play/queue/stop/voice`, `pause`, `.rpa`, `.rpyc` (через unrpyc), -автоопределение картинок из `images/`, GUI-текстбокс и шрифты игры. - -Пока **не** поддерживается: `screen`-язык (кастомные экраны и мини-игры), -анимации ATL, `layeredimage`, функции/классы в `python` — такие места -перечислены в отчёте конвертации у версии. +# PGames — локальная библиотека визуальных новелл + +Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит +скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM) +и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры +может быть несколько версий (форков) от разных пользователей; версии делят +общие ассеты, поэтому форк почти не занимает места. + +Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). + +## Установка на VM + +```bash +git clone pgames && cd pgames +cp .env.example .env +# обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли +docker compose up -d --build +``` + +Приложение слушает `http://:8080` (порт — `HTTP_PORT` в `.env`). +Caddy на хосте Proxmox: + +``` +games.example.lan { + reverse_proxy 192.168.1.50:8080 +} +``` + +Если в браузере сайт открывается по HTTPS через Caddy, оставьте +`COOKIE_SECURE=true`; при доступе напрямую по HTTP поставьте `false`, иначе +браузер не сохранит сессию. + +## Первый вход + +Логин `admin` / пароль из `ADMIN_PASSWORD` (по умолчанию `admin`). Сразу после +входа нужно задать свой логин и пароль. + +Регистрации нет: пользователей создаёт админ в разделе «Пользователи». +Сервер генерирует пароль и показывает его один раз — передайте его человеку, +при первом входе он задаст свой. Права настраиваются через роли +(«Роли»): `admin`, `moderator`, `player` плюс свои. + +## Импорт уже распакованных игр + +Если игры уже лежат на VM распакованными, архивировать их не нужно. +Подключите папку к контейнеру (только чтение) и запустите импорт: + +```bash +docker compose run --rm -v /srv/games:/games:ro backend \ + python -m app.import_dir "/games/Some-Game" --title "Some Game" +``` + +Папка не изменяется, сконвертированная копия попадает в библиотеку как +обычная загрузка. Проверить, насколько хорошо конвертер понимает игру, +не трогая медиа (минуты вместо часов), можно сухим прогоном: + +```bash +docker compose run --rm -v /srv/games:/games:ro backend \ + python -W ignore -m app.converter.analyze "/games/Some-Game" +``` + +## Данные и бэкап + +* `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256); +* том `db` — PostgreSQL (пользователи, игры, версии, сохранения). + +Для бэкапа достаточно `pg_dump` и копии `data/blobs`. + +## Разработка + +На Windows тестовый стенд поднимается одной командой: `start-test.bat`. +Он запускает отдельный compose-проект `pgames-test` на http://localhost:8090 +(свой `.env.test`, данные в `data-test\`), генерирует тестовую игру +`data-test\sample.zip` и открывает браузер. `start-test.bat stop` — остановить, +`reset` — удалить стенд вместе с данными, `logs` — логи бэкенда и воркера. + +```bash +cd backend && pip install -r requirements.txt pytest && python -m pytest +cd frontend && npm install && npm run dev # проксирует /api на :8000 +python -m tests.make_sample sample.zip # тестовая игра (из backend/) +``` + +## Что поддерживает конвертер Ren'Py + +`label`, реплики (в т.ч. атрибуты, `extend`, `centered`, NVL), `scene/show/hide` +с `at`/`as`/`behind`/`zorder`/`with`, переходы (`dissolve`, `fade`, +`Fade(...)`, `vpunch`, wipe/slide…), `menu` с условиями и `set`, `if/elif/else`, +`while`, `jump/call/return` (включая `expression`), `$`/`python:`/`init python:` +(подмножество Python: переменные, выражения, списки/словари, циклы), +`define/default`, `Character(...)`, `image` (файлы, цвета, `Transform`, `im.*`, +`Composite`, `ConditionSwitch`, `Movie`, покадровые ATL-анимации с `pause`/`repeat`), +`layeredimage` (always/attribute/group/if), `transform` со статическими +свойствами, `Shake`, `camera`, вложенные метки и именованные `menu`, +`call label(args)` и метки с параметрами, `play/queue/stop/voice` и свои +каналы (`register_channel`), `pause`, `.rpa`, `.rpyc` (через unrpyc), +автоопределение картинок из `images/`, GUI-текстбокс и шрифты игры. + +Пока **не** поддерживается: `screen`-язык (кастомные экраны, карты, +мини-игры), движение и твины в ATL, функции/классы в `python` и картинки, +которые игра вычисляет своими функциями. Такие места перечислены в отчёте +конвертации у версии. Игры-«песочницы», где навигация построена на экранах, +пока не играбельны. diff --git a/backend/app/converter/analyze.py b/backend/app/converter/analyze.py new file mode 100644 index 0000000..53d476d --- /dev/null +++ b/backend/app/converter/analyze.py @@ -0,0 +1,144 @@ +"""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) diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index 89d3028..d30f458 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -36,6 +36,7 @@ class Result: 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) @@ -44,9 +45,14 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress out_dir.mkdir(parents=True) data = ScriptData() - progress("Распаковка архива") - extract(archive, filename, src_root) - game_dir = find_game_dir(src_root) + 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): @@ -115,7 +121,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress 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}, ensure_ascii=False, + 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} @@ -138,6 +145,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress "transforms": data.transforms, "transitions": data.transitions, "audio": audio, + "channels": data.channels, "files": aliases, "defaults": data.defaults, "init": data.init_code, @@ -169,12 +177,18 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress {"message": msg, "count": n, "example": data.warning_examples.get(msg)} for msg, n in data.warnings.most_common() ], - "sourceSize": archive.stat().st_size, + "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") @@ -203,9 +217,47 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st 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(): diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index b5075ca..1f1313a 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -1,185 +1,189 @@ -"""Translate the subset of Python used by visual novels into PGVN IR. - -Expressions become JSON trees ``{"k": kind, ...}``; statements become -``{"op": ..., ...}`` nodes executed by the player's JS interpreter. -Anything outside the subset raises :class:`Unsupported` so the caller can -report it and degrade gracefully. -""" - -import ast -from typing import Any - -Expr = dict[str, Any] -Stmt = dict[str, Any] - -BIN_OPS = { - ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//", - ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^", - ast.LShift: "<<", ast.RShift: ">>", -} -CMP_OPS = { - ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=", - ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not", -} -UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"} - -# Modules whose import is harmless: the player provides the functions it supports. -KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"} - - -class Unsupported(Exception): - pass - - -def const(v: Any) -> Expr: - return {"k": "const", "v": v} - - -def parse_expr(source: str) -> Expr: - try: - tree = ast.parse(source.strip(), mode="eval") - except SyntaxError as e: - raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e - return expr(tree.body) - - -def parse_stmts(source: str) -> list[Stmt]: - try: - tree = ast.parse(_dedent(source)) - except SyntaxError as e: - raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e - return stmts(tree.body) - - -def _dedent(source: str) -> str: - import textwrap - return textwrap.dedent(source) - - -def literal(source: str) -> Any: - """Evaluate a constant expression at conversion time, or raise Unsupported.""" - try: - return ast.literal_eval(source.strip()) - except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e: - raise Unsupported(f"не константа: {source!r}") from e - - -def expr(node: ast.AST) -> Expr: - match node: - case ast.Constant(value=v): - if isinstance(v, bytes): - v = v.decode("utf-8", "replace") - if isinstance(v, complex) or v is Ellipsis: - raise Unsupported("неподдерживаемая константа") - return const(v) - case ast.Name(id=name): - return {"k": "name", "id": name} - case ast.Attribute(value=ast.Name(id="store"), attr=attr): - return {"k": "name", "id": attr} - case ast.Attribute(value=value, attr=attr): - return {"k": "attr", "o": expr(value), "a": attr} - case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)): - return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)} - case ast.Subscript(value=value, slice=index): - return {"k": "sub", "o": expr(value), "i": expr(index)} - case ast.BinOp(left=l, op=op, right=r): - if type(op) not in BIN_OPS: - raise Unsupported(f"оператор {type(op).__name__}") - return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)} - case ast.BoolOp(op=op, values=values): - return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or", - "v": [expr(v) for v in values]} - case ast.UnaryOp(op=op, operand=operand): - return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)} - case ast.Compare(left=l, ops=ops, comparators=rs): - return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops], - "r": [expr(r) for r in rs]} - case ast.IfExp(test=t, body=b, orelse=e): - return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)} - case ast.List(elts=elts) | ast.Set(elts=elts): - return {"k": "list", "v": [expr(e) for e in elts]} - case ast.Tuple(elts=elts): - return {"k": "tuple", "v": [expr(e) for e in elts]} - case ast.Dict(keys=keys, values=values): - if any(k is None for k in keys): - raise Unsupported("распаковка ** в словаре") - return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]} - case ast.Call(func=func, args=args, keywords=kws): - if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws): - raise Unsupported("распаковка аргументов *args/**kwargs") - return {"k": "call", "f": expr(func), "a": [expr(a) for a in args], - "kw": {k.arg: expr(k.value) for k in kws}} - case ast.JoinedStr(values=values): - parts: list[Any] = [] - for v in values: - if isinstance(v, ast.Constant): - parts.append(v.value) - elif isinstance(v, ast.FormattedValue): - parts.append(expr(v.value)) - return {"k": "fmt", "v": parts} - case ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs, - is_async=0)]): - return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it), - "if": [expr(i) for i in ifs]} - case ast.Lambda(): - raise Unsupported("lambda") - raise Unsupported(f"выражение {type(node).__name__}") - - -def _opt(node: ast.AST | None) -> Expr | None: - return None if node is None else expr(node) - - -def target(node: ast.AST) -> Expr: - match node: - case ast.Name() | ast.Attribute() | ast.Subscript(): - return expr(node) - case ast.Tuple(elts=elts) | ast.List(elts=elts): - return {"k": "tuple", "v": [target(e) for e in elts]} - raise Unsupported(f"присваивание в {type(node).__name__}") - - -def stmts(nodes: list[ast.stmt]) -> list[Stmt]: - out: list[Stmt] = [] - for n in nodes: - out.extend(stmt(n)) - return out - - -def stmt(node: ast.stmt) -> list[Stmt]: - match node: - case ast.Assign(targets=targets, value=value): - v = expr(value) - return [{"op": "set", "t": target(t), "v": v} for t in targets] - case ast.AnnAssign(target=t, value=value) if value is not None: - return [{"op": "set", "t": target(t), "v": expr(value)}] - case ast.AugAssign(target=t, op=op, value=value): - if type(op) not in BIN_OPS: - raise Unsupported(f"оператор {type(op).__name__}") - return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}] - case ast.Expr(value=ast.Constant()): - return [] # docstring / bare literal - case ast.Expr(value=value): - return [{"op": "eval", "e": expr(value)}] - case ast.If(test=test, body=body, orelse=orelse): - return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}] - case ast.While(test=test, body=body, orelse=[]): - return [{"op": "while", "c": expr(test), "body": stmts(body)}] - case ast.For(target=t, iter=it, body=body, orelse=[]): - return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}] - case ast.Delete(targets=targets): - return [{"op": "del", "t": target(t)} for t in targets] - case ast.Pass() | ast.Global() | ast.Nonlocal(): - return [] - case ast.Break(): - return [{"op": "break"}] - case ast.Continue(): - return [{"op": "continue"}] - case ast.Import(names=names) | ast.ImportFrom(names=names): - mod = getattr(node, "module", None) or names[0].name - if mod.split(".")[0] in KNOWN_MODULES: - return [] - raise Unsupported(f"импорт модуля {mod}") - case ast.FunctionDef(name=name) | ast.ClassDef(name=name): - raise Unsupported(f"определение {name}()") - raise Unsupported(f"оператор {type(node).__name__}") +"""Translate the subset of Python used by visual novels into PGVN IR. + +Expressions become JSON trees ``{"k": kind, ...}``; statements become +``{"op": ..., ...}`` nodes executed by the player's JS interpreter. +Anything outside the subset raises :class:`Unsupported` so the caller can +report it and degrade gracefully. +""" + +import ast +from typing import Any + +Expr = dict[str, Any] +Stmt = dict[str, Any] + +BIN_OPS = { + ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//", + ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^", + ast.LShift: "<<", ast.RShift: ">>", +} +CMP_OPS = { + ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=", + ast.In: "in", ast.NotIn: "not in", ast.Is: "is", ast.IsNot: "is not", +} +UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"} + +# Modules whose import is harmless: the player provides the functions it supports. +KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"} + + +class Unsupported(Exception): + pass + + +def const(v: Any) -> Expr: + return {"k": "const", "v": v} + + +def parse_expr(source: str) -> Expr: + try: + tree = ast.parse(source.strip(), mode="eval") + except SyntaxError as e: + raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e + return expr(tree.body) + + +def parse_stmts(source: str) -> list[Stmt]: + try: + tree = ast.parse(_dedent(source)) + except SyntaxError as e: + raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e + return stmts(tree.body) + + +def _dedent(source: str) -> str: + import textwrap + return textwrap.dedent(source) + + +def literal(source: str) -> Any: + """Evaluate a constant expression at conversion time, or raise Unsupported.""" + try: + return ast.literal_eval(source.strip()) + except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e: + raise Unsupported(f"не константа: {source!r}") from e + + +def expr(node: ast.AST) -> Expr: + match node: + case ast.Constant(value=v): + if isinstance(v, bytes): + v = v.decode("utf-8", "replace") + if isinstance(v, complex) or v is Ellipsis: + raise Unsupported("неподдерживаемая константа") + return const(v) + case ast.Name(id=name): + return {"k": "name", "id": name} + case ast.Attribute(value=ast.Name(id="store"), attr=attr): + return {"k": "name", "id": attr} + case ast.Attribute(value=value, attr=attr): + return {"k": "attr", "o": expr(value), "a": attr} + case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)): + return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)} + case ast.Subscript(value=value, slice=index): + return {"k": "sub", "o": expr(value), "i": expr(index)} + case ast.BinOp(left=l, op=op, right=r): + if type(op) not in BIN_OPS: + raise Unsupported(f"оператор {type(op).__name__}") + return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)} + case ast.BoolOp(op=op, values=values): + return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or", + "v": [expr(v) for v in values]} + case ast.UnaryOp(op=op, operand=operand): + return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)} + case ast.Compare(left=l, ops=ops, comparators=rs): + return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops], + "r": [expr(r) for r in rs]} + case ast.IfExp(test=t, body=b, orelse=e): + return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)} + case ast.List(elts=elts) | ast.Set(elts=elts): + return {"k": "list", "v": [expr(e) for e in elts]} + case ast.Tuple(elts=elts): + return {"k": "tuple", "v": [expr(e) for e in elts]} + case ast.Dict(keys=keys, values=values): + if any(k is None for k in keys): + raise Unsupported("распаковка ** в словаре") + return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]} + case ast.Call(func=func, args=args, keywords=kws): + if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws): + raise Unsupported("распаковка аргументов *args/**kwargs") + return {"k": "call", "f": expr(func), "a": [expr(a) for a in args], + "kw": {k.arg: expr(k.value) for k in kws}} + case ast.JoinedStr(values=values): + parts: list[Any] = [] + for v in values: + if isinstance(v, ast.Constant): + parts.append(v.value) + elif isinstance(v, ast.FormattedValue): + parts.append(expr(v.value)) + return {"k": "fmt", "v": parts} + case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs, + is_async=0)]) + | ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it, + ifs=ifs, is_async=0)]) + | ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs, + is_async=0)])): + return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it), + "if": [expr(i) for i in ifs]} + case ast.Lambda(): + raise Unsupported("lambda") + raise Unsupported(f"выражение {type(node).__name__}") + + +def _opt(node: ast.AST | None) -> Expr | None: + return None if node is None else expr(node) + + +def target(node: ast.AST) -> Expr: + match node: + case ast.Name() | ast.Attribute() | ast.Subscript(): + return expr(node) + case ast.Tuple(elts=elts) | ast.List(elts=elts): + return {"k": "tuple", "v": [target(e) for e in elts]} + raise Unsupported(f"присваивание в {type(node).__name__}") + + +def stmts(nodes: list[ast.stmt]) -> list[Stmt]: + out: list[Stmt] = [] + for n in nodes: + out.extend(stmt(n)) + return out + + +def stmt(node: ast.stmt) -> list[Stmt]: + match node: + case ast.Assign(targets=targets, value=value): + v = expr(value) + return [{"op": "set", "t": target(t), "v": v} for t in targets] + case ast.AnnAssign(target=t, value=value) if value is not None: + return [{"op": "set", "t": target(t), "v": expr(value)}] + case ast.AugAssign(target=t, op=op, value=value): + if type(op) not in BIN_OPS: + raise Unsupported(f"оператор {type(op).__name__}") + return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}] + case ast.Expr(value=ast.Constant()): + return [] # docstring / bare literal + case ast.Expr(value=value): + return [{"op": "eval", "e": expr(value)}] + case ast.If(test=test, body=body, orelse=orelse): + return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}] + case ast.While(test=test, body=body, orelse=[]): + return [{"op": "while", "c": expr(test), "body": stmts(body)}] + case ast.For(target=t, iter=it, body=body, orelse=[]): + return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}] + case ast.Delete(targets=targets): + return [{"op": "del", "t": target(t)} for t in targets] + case ast.Pass() | ast.Global() | ast.Nonlocal(): + return [] + case ast.Break(): + return [{"op": "break"}] + case ast.Continue(): + return [{"op": "continue"}] + case ast.Import(names=names) | ast.ImportFrom(names=names): + mod = getattr(node, "module", None) or names[0].name + if mod.split(".")[0] in KNOWN_MODULES: + return [] + raise Unsupported(f"импорт модуля {mod}") + case ast.FunctionDef(name=name) | ast.ClassDef(name=name): + raise Unsupported(f"определение {name}()") + raise Unsupported(f"оператор {type(node).__name__}") diff --git a/backend/app/converter/rpa.py b/backend/app/converter/rpa.py index 27a49ed..80aa135 100644 --- a/backend/app/converter/rpa.py +++ b/backend/app/converter/rpa.py @@ -7,6 +7,7 @@ unpickler that only allows plain containers — archives are untrusted input. import io import pickle import zlib +from collections.abc import Callable from pathlib import Path @@ -65,13 +66,15 @@ def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]: return index -def extract_rpa(path: Path, dest: Path) -> int: - """Extract all files of an archive into dest. Returns the number of files.""" +def extract_rpa(path: Path, dest: Path, only: Callable[[str], bool] | None = None) -> int: + """Extract files of an archive into dest (all, or those `only` accepts).""" index = read_index(path) root = dest.resolve() count = 0 with path.open("rb") as f: for name, entries in index.items(): + if only is not None and not only(name): + continue target = (dest / name).resolve() if not target.is_relative_to(root): continue diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index fe81659..b1ca22d 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -1,6 +1,8 @@ """Ren'Py script (.rpy) → PGVN IR.""" import ast +import re +import warnings from collections import Counter from dataclasses import dataclass, field from typing import Any @@ -43,7 +45,7 @@ POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xce "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset", "align", "pos", "anchor", "xysize", "size"} CHANNELS = {"music", "sound", "voice", "audio", "movie"} -SKIP_BLOCKS = {"screen", "style", "translate", "testcase", "layeredimage", "camera"} +SKIP_BLOCKS = {"screen", "style", "translate", "testcase"} BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"} @@ -57,6 +59,7 @@ class ScriptData: defaults: list[list[Any]] = field(default_factory=list) init_code: list[Stmt] = field(default_factory=list) # defines + init python, in order gui: dict[str, Any] = field(default_factory=dict) + channels: dict[str, dict[str, Any]] = field(default_factory=dict) config: dict[str, Any] = field(default_factory=dict) modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels warnings: Counter = field(default_factory=Counter) @@ -73,6 +76,11 @@ class RpyParser: def __init__(self, data: ScriptData, filename: str): self.d = data self.filename = filename + self.global_label = "" + + def label_ref(self, name: str) -> str: + """Resolve `.local` label names against the enclosing global label.""" + return self.global_label + name if name.startswith(".") else name # --- helpers ------------------------------------------------------------- @@ -92,7 +100,7 @@ class RpyParser: def python(self, source: str, line: Line) -> list[Stmt]: """Translate python code statement by statement, skipping unsupported ones.""" try: - tree = ast.parse(_dedent(source)) + tree = _parse_py(source) except SyntaxError as e: self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line)) return [] @@ -114,13 +122,12 @@ class RpyParser: c = self.cursor(line) first = c.peek_word() if first == "label": - c.keyword("label") - name = c.label_name() - if not name: - raise c.error("ожидалось имя метки") - if c.char("("): - self.d.warn("Параметры меток не поддерживаются", self.where(line)) - current = {"name": name, "body": self.block(line.block)} + name, params = self.label_header(line) + if not name.startswith("."): + self.global_label = name + current = {"name": self.label_ref(name), "body": self.block(line.block)} + if params: + current["params"] = params labels.append(current) continue nodes = self.statement(line, top_level=True) @@ -143,7 +150,15 @@ class RpyParser: out.append({"op": "if", "branches": branches}) continue if word == "label": - self.d.warn("Вложенные метки не поддерживаются", self.where(line)) + # a label inside a block is an entry point in the middle of the flow: + # keep a marker the player can jump to and inline the label's block + name, params = self.label_header(line) + if not name.startswith("."): + self.global_label = name + marker: Node = {"op": "label", "name": self.label_ref(name)} + if params: + marker["params"] = params + out.append(marker) out.extend(self.block(line.block)) i += 1 continue @@ -151,6 +166,37 @@ class RpyParser: i += 1 return out + def label_header(self, line: Line) -> tuple[str, list[list[Any]] | None]: + c = self.cursor(line) + c.keyword("label") + name = c.label_name() + if not name: + raise c.error("ожидалось имя метки") + params = None + if c.peek("("): + sig = c.until(set(), ":") + try: + fn = _parse_py(f"def _f{sig}: pass").body[0] + args = fn.args + defaults = [None] * (len(args.args) - len(args.defaults)) + list(args.defaults) + params = [[a.arg, pyexpr.expr(d) if d is not None else None] + for a, d in zip(args.args, defaults)] + except (SyntaxError, Unsupported): + self.d.warn("Не разобраны параметры метки", self.where(line)) + return name, params + + def call_args(self, c: Cursor, line: Line) -> dict[str, Any] | None: + if not c.peek("("): + return None + src = c.until({"from"}) + try: + call = _parse_py(f"_f{src}", mode="eval").body + return {"a": [pyexpr.expr(a) for a in call.args], + "kw": {k.arg: pyexpr.expr(k.value) for k in call.keywords}} + except (SyntaxError, Unsupported, AttributeError): + self.d.warn("Не разобраны аргументы call", self.where(line)) + return None + def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]: branches: list[Node] = [] while i < len(lines): @@ -282,11 +328,15 @@ class RpyParser: def init_python(self, source: str, line: Line) -> None: try: - tree = ast.parse(_dedent(source)) + tree = _parse_py(source) except SyntaxError as e: self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line)) return for node in tree.body: + if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) + and ast.unparse(node.value.func) == "renpy.music.register_channel"): + self.register_channel(node.value) + continue if isinstance(node, ast.Assign) and len(node.targets) == 1: tgt = node.targets[0] name = _assign_name(tgt) @@ -306,6 +356,21 @@ class RpyParser: except Unsupported as e: self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}") + def register_channel(self, call: ast.Call) -> None: + args = list(call.args) + [None] * 3 + kw = {k.arg: k.value for k in call.keywords} + + def lit(node, default=None): + try: + return ast.literal_eval(node) if node is not None else default + except ValueError: + return default + + name = lit(args[0]) + if isinstance(name, str): + self.d.channels[name] = {"mixer": lit(kw.get("mixer", args[1]), "sfx"), + "loop": bool(lit(kw.get("loop", args[2]), False))} + # define / default / image / transform ------------------------------------------- def st_define(self, line: Line) -> list[Node]: @@ -499,25 +564,182 @@ class RpyParser: def atl_image(self, line: Line) -> dict[str, Any] | None: frames: list[dict[str, Any]] = [] props: dict[str, Any] = {} - for child in line.block: + repeat = self.atl_frames(line.block, frames, props) + if not frames: + self.d.warn("ATL-изображение без кадров", self.where(line)) + return None + if len(frames) == 1: + img = dict(frames[0]["img"]) + else: + img = {"frames": frames, "repeat": repeat} + if props: + img["props"] = {**img.get("props", {}), **props} + return img + + def atl_frames(self, lines: list[Line], frames: list[dict[str, Any]], + props: dict[str, Any]) -> bool: + """Collect displayable frames of an ATL block. Returns whether it repeats.""" + repeat = False + for child in lines: c = self.cursor(child) + word = c.peek_word() + if word in ("contains", "block", "parallel", "choice"): + if not frames or word == "block": + repeat = self.atl_frames(child.block, frames, props) or repeat + continue + if word == "repeat": + repeat = True + continue + if word in ("pause",) or _NUMBER_LINE.match(child.text): + c.keyword("pause") + d = _num(c.number()) + if frames and d is not None: + frames[-1]["d"] = frames[-1].get("d", 0) + d + continue + if word in ("function", "on", "event", "time", "transform"): + continue s = c.string() if s is not None: img = self.displayable_expr(const(s), child) if img: - frames.append(img) + frames.append({"img": img}) continue + m = _CALL_LINE.match(child.text) + if m and m.group(1) in _DISPLAYABLE_CALLS: + e = self.expr(child.text.split(" with ")[0], child) + img = self.displayable_expr(e, child) if e else None + if img: + frames.append({"img": img}) + continue + if m and not frames and not _atl_props(child.text): + # e.g. get_face_path("base"): an image computed by game code + e = self.expr(child.text, child) + if e is not None: + self.d.warn("Изображение задано выражением", self.where(child)) + frames.append({"img": {"expr": e}}) + continue + if not frames or not frames[-1].get("d"): + props.update(_atl_props(child.text)) + return repeat + + def st_layeredimage(self, line: Line) -> list[Node]: + c = self.cursor(line) + c.keyword("layeredimage") + parts: list[str] = [] + while (p := c.image_part()) is not None: + parts.append(p) + name = " ".join(parts) + layers: list[dict[str, Any]] = [] + at: list[dict[str, Any]] = [] + for child in line.block: + cc = self.cursor(child) + kw = cc.word() + if kw == "always": + img = self.layer_image(cc, child, None) + if img: + layers.append({"always": img}) + elif kw == "attribute": + layers.append(self.layer_attribute(cc, child, name, None)) + elif kw == "group": + group = cc.word() or "" + auto = False + while not cc.eol(): + w = cc.word() + if w == "auto": + auto = True + elif w in ("multiple", "prefix", "variant"): + cc.word() if w in ("prefix", "variant") else None + elif w is None: + break + for sub in child.block: + sc = self.cursor(sub) + if sc.keyword("attribute"): + layers.append(self.layer_attribute(sc, sub, name, group)) + if auto: + layers.append({"autoGroup": group}) + elif kw == "if": + cond = self.expr(cc.rest().removesuffix(":"), child, const(False)) + img = self.layer_image(Cursor(""), child, None) + if img: + layers.append({"cond": [[cond, img]]}) + elif kw in ("elif", "else"): + img = self.layer_image(Cursor(""), child, None) + if img and layers and "cond" in layers[-1]: + cond = None + if kw == "elif": + cond = self.expr(cc.rest().removesuffix(":"), child, const(False)) + layers[-1]["cond"].append([cond, img]) + elif kw == "at": + for item in _split_top(cc.rest()): + at.append(self.transform_ref(item, child)) + self.d.images[name] = {"layered": layers, "at": at} + self.d.stats["layeredimage"] += 1 + return [] + + def layer_attribute(self, c: Cursor, line: Line, image: str, group: str | None + ) -> dict[str, Any]: + attr = c.image_part() or "" + layer: dict[str, Any] = {"attr": attr, "group": group} + img = None + while not c.eol(): + if c.peek('"') or c.peek("'"): + img = self.displayable_expr(const(c.string()), line) + continue + w = c.word() + if w == "default": + layer["default"] = True + elif w == "null": + layer["null"] = True + elif w in ("if_any", "if_all", "if_not"): + src = c.until({"if_any", "if_all", "if_not", "default", "null", "variant", "at"}, + ":") + try: + val = pyexpr.literal(src) + layer[w] = [val] if isinstance(val, str) else list(val) + except Unsupported: + pass + elif w in ("variant", "at"): + c.until({"if_any", "if_all", "if_not", "default", "null"}, ":") + elif w is None: + break + if img is None and line.block: + img = self.layer_image(Cursor(""), line, None) + if img is None and not layer.get("null"): + # Ren'Py's default naming: __ + img = {"ref": "_".join(x for x in (image.replace(" ", "_"), group, attr) if x)} + if img: + layer["img"] = img + return layer + + def layer_image(self, c: Cursor, line: Line, default) -> dict[str, Any] | None: + s = c.string() if c.text else None + if s is not None: + return self.displayable_expr(const(s), line) + for child in line.block: + cc = self.cursor(child) + s = cc.string() + if s is not None: + return self.displayable_expr(const(s), child) + m = _CALL_LINE.match(child.text) + if m and m.group(1) in _DISPLAYABLE_CALLS: + e = self.expr(child.text, child) + return self.displayable_expr(e, child) if e else None + return default + + def st_camera(self, line: Line) -> list[Node]: + c = self.cursor(line) + c.keyword("camera") + c.word() if not c.peek("at") and not c.peek(":") else None + node: Node = {"op": "camera", "at": []} + if c.keyword("at"): + for item in _split_top(c.until(set(), ":")): + node["at"].append(self.transform_ref(item, line)) + props: dict[str, Any] = {} + for child in line.block: props.update(_atl_props(child.text)) - if not frames: - self.d.warn("ATL-изображение без кадров", self.where(line)) - return None - if len(frames) > 1: - self.d.warn("Анимированные ATL-изображения упрощены до первого кадра", - self.where(line)) - img = dict(frames[0]) if props: - img["props"] = {**img.get("props", {}), **props} - return img + node["at"].append({"props": props}) + return [node] def st_transform(self, line: Line) -> list[Node]: c = self.cursor(line) @@ -588,6 +810,10 @@ class RpyParser: fname = _dotted_name(e["f"]) if fname in ("Position", "Transform"): return {"props": _static_props(e["kw"])} + if fname in ("Shake", "SnoopyShake"): + args = [_const(a) for a in e["a"]] + return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5, + "dist": _const(e["kw"].get("dist")) or 20} if fname in self.d.transforms: return {"props": self.d.transforms[fname], "name": fname} if e and e["k"] == "name" and e["id"] not in self.d.transforms: @@ -615,8 +841,12 @@ class RpyParser: self.d.warn("Не поддерживается: screen", self.where(line)) return [] if c.keyword("text"): - text = c.string() or "" - node: Node = {"op": "show", "img": {"text": text}} + text = c.string() + if text is not None: + node: Node = {"op": "show", "img": {"text": text}} + else: + node = {"op": "show", "img": {"textExpr": self.expr(c.until(_SHOW_KEYWORDS), line, + const(""))}} self.show_clauses(c, line, node) return [node] node = {"op": "show", "img": self.image_spec(c, line)} @@ -668,6 +898,8 @@ class RpyParser: fin = nums[2] if len(nums) > 2 else (nums[1] if len(nums) > 1 else out) return {"t": "fade", "out": out, "hold": hold, "in": fin, "color": kw.get("color") or "#000000"} + if fname in ("Shake", "SnoopyShake"): + return {"t": "punch", "dir": "h", "d": nums[0] if nums else 0.5} if fname in ("MoveTransition", "Move"): return {"t": "move", "d": nums[0] if nums else 0.5} if fname in TRANSITIONS: @@ -690,8 +922,6 @@ class RpyParser: c = self.cursor(line) c.keyword(op) ch = c.word() or "music" - if ch not in CHANNELS: - self.d.warn(f"Нестандартный аудиоканал {ch}", self.where(line)) node: Node = {"op": op, "ch": ch, "files": self.audio_files(c, line)} while not c.eol(): if c.keyword("fadein"): @@ -737,7 +967,7 @@ class RpyParser: c.keyword("jump") if c.keyword("expression"): return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}] - return [{"op": "jump", "target": c.label_name()}] + return [{"op": "jump", "target": self.label_ref(c.label_name() or "")}] def st_call(self, line: Line) -> list[Node]: c = self.cursor(line) @@ -748,10 +978,12 @@ class RpyParser: if c.keyword("expression"): node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line, const(""))} + c.keyword("pass") else: - node = {"op": "call", "target": c.label_name()} - if c.peek("("): - self.d.warn("Аргументы call не поддерживаются", self.where(line)) + node = {"op": "call", "target": self.label_ref(c.label_name() or "")} + args = self.call_args(c, line) + if args: + node["args"] = args return [node] def st_return(self, line: Line) -> list[Node]: @@ -791,11 +1023,19 @@ class RpyParser: def st_menu(self, line: Line) -> list[Node]: node: Node = {"op": "menu", "items": [], "say": None} + head = self.cursor(line) + head.keyword("menu") + name = head.label_name() + out: list[Node] = [] + if name: + out.append({"op": "label", "name": self.label_ref(name)}) for child in line.block: c = self.cursor(child) if c.keyword("set"): node["set"] = self.expr(c.rest(), child) continue + if c.keyword("with"): + continue text = c.string() if text is not None and child.text.rstrip().endswith(":"): cond = None @@ -813,7 +1053,7 @@ class RpyParser: else: self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}") self.d.stats["menu"] += 1 - return [node] + return out + [node] # say ------------------------------------------------------------------------------ @@ -871,6 +1111,11 @@ class RpyParser: _SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} +_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$") +_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(") +_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed", + "ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip", + "im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"} _MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus", "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} @@ -880,6 +1125,13 @@ def _dedent(source: str) -> str: return textwrap.dedent(source) +def _parse_py(source: str, mode: str = "exec") -> ast.AST: + # decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return ast.parse(_dedent(source), mode=mode) + + def _dotted_name(e: Expr) -> str: if e["k"] == "name": return e["id"] diff --git a/backend/app/import_dir.py b/backend/app/import_dir.py new file mode 100644 index 0000000..32a4292 --- /dev/null +++ b/backend/app/import_dir.py @@ -0,0 +1,48 @@ +"""Import an already unpacked game from a folder on the server. + + python -m app.import_dir /games/Some-Game [--title "Name"] [--owner admin] + +The folder is only read; the converted copy goes to the blob store like an upload. +""" + +import argparse +import asyncio +from pathlib import Path + +from sqlalchemy import select + +from app.db import SessionLocal +from app.models import Game, GameVersion, Role, User +from app.worker import convert + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("path", type=Path) + ap.add_argument("--title") + ap.add_argument("--owner", help="username; defaults to the first admin") + args = ap.parse_args() + if not args.path.is_dir(): + raise SystemExit(f"{args.path} is not a directory") + + async with SessionLocal() as db: + stmt = select(User).join(Role).order_by(User.id) + stmt = stmt.where(User.username == args.owner) if args.owner else stmt.where(Role.name == "admin") + owner = await db.scalar(stmt.limit(1)) + if owner is None: + raise SystemExit("owner not found") + game = Game(title=args.title or args.path.name, created_by_id=owner.id) + db.add(game) + await db.flush() + version = GameVersion(game_id=game.id, owner_id=owner.id, name="Оригинал", + upload_name=args.path.name, status="importing") + db.add(version) + await db.commit() + game_id, version_id = game.id, version.id + + ok = await convert(version_id, args.path, args.path.name) + print(f"game {game_id}, version {version_id}: {'ready' if ok else 'failed'}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/app/models.py b/backend/app/models.py index 63b90eb..fef2904 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,99 +1,99 @@ -from datetime import datetime -from typing import Any - -from sqlalchemy import ( - ARRAY, - BigInteger, - Boolean, - DateTime, - ForeignKey, - Integer, - String, - Text, - UniqueConstraint, - func, -) -from sqlalchemy.dialects.postgresql import JSONB -from sqlalchemy.orm import Mapped, mapped_column, relationship - -from app.db import Base - - -class Role(Base): - __tablename__ = "roles" - - id: Mapped[int] = mapped_column(primary_key=True) - name: Mapped[str] = mapped_column(String(64), unique=True) - permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) - is_system: Mapped[bool] = mapped_column(Boolean, default=False) - - -class User(Base): - __tablename__ = "users" - - id: Mapped[int] = mapped_column(primary_key=True) - username: Mapped[str] = mapped_column(String(64), unique=True) - password_hash: Mapped[str] = mapped_column(String(255)) - role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) - is_active: Mapped[bool] = mapped_column(Boolean, default=True) - must_change_password: Mapped[bool] = mapped_column(Boolean, default=True) - must_change_username: Mapped[bool] = mapped_column(Boolean, default=False) - token_version: Mapped[int] = mapped_column(Integer, default=0) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - role: Mapped[Role] = relationship(lazy="joined") - - @property - def permissions(self) -> list[str]: - return sorted(self.role.permissions) if self.is_active else [] - - -class Game(Base): - __tablename__ = "games" - - id: Mapped[int] = mapped_column(primary_key=True) - title: Mapped[str] = mapped_column(String(255)) - description: Mapped[str] = mapped_column(Text, default="") - tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) - engine: Mapped[str] = mapped_column(String(32), default="renpy") - cover_hash: Mapped[str | None] = mapped_column(String(80)) - created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - created_by: Mapped[User | None] = relationship(lazy="joined") - - -class GameVersion(Base): - __tablename__ = "game_versions" - - id: Mapped[int] = mapped_column(primary_key=True) - game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True) - parent_id: Mapped[int | None] = mapped_column(ForeignKey("game_versions.id", ondelete="SET NULL")) - owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) - name: Mapped[str] = mapped_column(String(255)) - description: Mapped[str] = mapped_column(Text, default="") - # uploading -> queued -> converting -> ready | failed - status: Mapped[str] = mapped_column(String(16), default="uploading", index=True) - progress: Mapped[str] = mapped_column(String(255), default="") - upload_name: Mapped[str | None] = mapped_column(String(255)) - manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB) - report: Mapped[dict[str, Any] | None] = mapped_column(JSONB) - size_bytes: Mapped[int] = mapped_column(BigInteger, default=0) - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) - - owner: Mapped[User | None] = relationship(lazy="joined") - - -class Save(Base): - __tablename__ = "saves" - __table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),) - - id: Mapped[int] = mapped_column(primary_key=True) - user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) - version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE")) - slot: Mapped[str] = mapped_column(String(32)) - title: Mapped[str] = mapped_column(String(255), default="") - data: Mapped[dict[str, Any]] = mapped_column(JSONB) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), server_default=func.now(), onupdate=func.now() - ) +from datetime import datetime +from typing import Any + +from sqlalchemy import ( + ARRAY, + BigInteger, + Boolean, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db import Base + + +class Role(Base): + __tablename__ = "roles" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(64), unique=True) + permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) + is_system: Mapped[bool] = mapped_column(Boolean, default=False) + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + username: Mapped[str] = mapped_column(String(64), unique=True) + password_hash: Mapped[str] = mapped_column(String(255)) + role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) + is_active: Mapped[bool] = mapped_column(Boolean, default=True) + must_change_password: Mapped[bool] = mapped_column(Boolean, default=True) + must_change_username: Mapped[bool] = mapped_column(Boolean, default=False) + token_version: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + role: Mapped[Role] = relationship(lazy="joined") + + @property + def permissions(self) -> list[str]: + return sorted(self.role.permissions) if self.is_active else [] + + +class Game(Base): + __tablename__ = "games" + + id: Mapped[int] = mapped_column(primary_key=True) + title: Mapped[str] = mapped_column(String(255)) + description: Mapped[str] = mapped_column(Text, default="") + tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) + engine: Mapped[str] = mapped_column(String(32), default="renpy") + cover_hash: Mapped[str | None] = mapped_column(String(80)) + created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + created_by: Mapped[User | None] = relationship(lazy="joined") + + +class GameVersion(Base): + __tablename__ = "game_versions" + + id: Mapped[int] = mapped_column(primary_key=True) + game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=True) + parent_id: Mapped[int | None] = mapped_column(ForeignKey("game_versions.id", ondelete="SET NULL")) + owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str] = mapped_column(Text, default="") + # uploading -> queued -> converting -> ready | failed; importing = app.import_dir run + status: Mapped[str] = mapped_column(String(16), default="uploading", index=True) + progress: Mapped[str] = mapped_column(String(255), default="") + upload_name: Mapped[str | None] = mapped_column(String(255)) + manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + report: Mapped[dict[str, Any] | None] = mapped_column(JSONB) + size_bytes: Mapped[int] = mapped_column(BigInteger, default=0) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + owner: Mapped[User | None] = relationship(lazy="joined") + + +class Save(Base): + __tablename__ = "saves" + __table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),) + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) + version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE")) + slot: Mapped[str] = mapped_column(String(32)) + title: Mapped[str] = mapped_column(String(255), default="") + data: Mapped[dict[str, Any]] = mapped_column(JSONB) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/routers/versions.py b/backend/app/routers/versions.py index d5fc7f5..3c0ed6f 100644 --- a/backend/app/routers/versions.py +++ b/backend/app/routers/versions.py @@ -1,201 +1,201 @@ -from typing import Annotated, Any - -from fastapi import APIRouter, Depends, HTTPException, Request, status -from sqlalchemy import select - -from app.deps import DB, CurrentUser, has_perm, require -from app.models import GameVersion, Save, User -from app.routers.games import get_game -from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut -from app.uploads import remove_upload, upload_path - -router = APIRouter(prefix="/api", tags=["versions"]) -Player = Annotated[User, Depends(require("games.play"))] -Uploader = Annotated[User, Depends(require("games.upload"))] - -ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz") - - -def can_see(user: User, v: GameVersion) -> bool: - return v.status == "ready" or v.owner_id == user.id or can_manage(user, v) - - -def can_manage(user: User, v: GameVersion) -> bool: - return ( - v.owner_id == user.id - or has_perm(user, "versions.manage_any") - or has_perm(user, "games.delete") - ) - - -async def get_version(db: DB, user: User, version_id: int) -> GameVersion: - v = await db.get(GameVersion, version_id) - if not v or not can_see(user, v): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена") - return v - - -@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201) -async def upload_version( - game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал" -) -> GameVersion: - """Stream a raw archive body to disk and queue it for conversion.""" - game = await get_game(db, game_id) - if not filename.lower().endswith(ARCHIVE_EXTS): - raise HTTPException(status.HTTP_400_BAD_REQUEST, - f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}") - version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал", - upload_name=filename, status="uploading") - db.add(version) - await db.commit() - - path = upload_path(version.id) - path.parent.mkdir(parents=True, exist_ok=True) - size = 0 - try: - with path.open("wb") as f: - async for chunk in request.stream(): - f.write(chunk) - size += len(chunk) - except Exception: - remove_upload(version.id) - await db.delete(version) - await db.commit() - raise - if size == 0: - remove_upload(version.id) - await db.delete(version) - await db.commit() - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл") - - version.status = "queued" - version.progress = "В очереди на конвертацию" - version.size_bytes = size - await db.commit() - return version - - -@router.get("/versions/{version_id}", response_model=VersionDetailOut) -async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion: - return await get_version(db, user, version_id) - - -@router.get("/versions/{version_id}/manifest") -async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]: - v = await get_version(db, user, version_id) - if v.status != "ready" or not v.manifest: - raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") - return v.manifest - - -@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201) -async def fork_version(version_id: int, body: VersionIn, - user: Annotated[User, Depends(require("games.play", "versions.fork"))], - db: DB) -> GameVersion: - parent = await get_version(db, user, version_id) - if parent.status != "ready": - raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию") - fork = GameVersion( - game_id=parent.game_id, - parent_id=parent.id, - owner_id=user.id, - name=body.name.strip(), - description=body.description, - status="ready", - manifest=parent.manifest, - report=parent.report, - size_bytes=parent.size_bytes, - ) - db.add(fork) - await db.commit() - return fork - - -@router.patch("/versions/{version_id}", response_model=VersionOut) -async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion: - v = await get_version(db, user, version_id) - if not can_manage(user, v): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") - v.name = body.name.strip() - v.description = body.description - await db.commit() - return v - - -@router.post("/versions/{version_id}/retry", response_model=VersionOut) -async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion: - v = await get_version(db, user, version_id) - if not can_manage(user, v): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") - if v.status != "failed" or not upload_path(v.id).exists(): - raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна") - v.status = "queued" - v.progress = "В очереди на конвертацию" - await db.commit() - return v - - -@router.delete("/versions/{version_id}", status_code=204) -async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None: - v = await get_version(db, user, version_id) - if not can_manage(user, v): - raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") - if v.status == "converting": - raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется") - await db.delete(v) - await db.commit() - remove_upload(version_id) - - -# --- saves ----------------------------------------------------------------- - -def _check_slot(slot: str) -> str: - if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot): - raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот") - return slot - - -@router.get("/versions/{version_id}/saves", response_model=list[SaveOut]) -async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]: - await get_version(db, user, version_id) - return list((await db.scalars( - select(Save).where(Save.user_id == user.id, Save.version_id == version_id) - .order_by(Save.updated_at.desc()) - )).all()) - - -async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None: - return await db.scalar(select(Save).where( - Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot))) - - -@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut) -async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save: - save = await _find_save(db, user, version_id, slot) - if not save: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено") - return save - - -@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut) -async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save: - await get_version(db, user, version_id) - save = await _find_save(db, user, version_id, slot) - if save is None: - save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255], - data=body.data) - db.add(save) - else: - save.title = body.title[:255] - save.data = body.data - await db.commit() - await db.refresh(save) - return save - - -@router.delete("/versions/{version_id}/saves/{slot}", status_code=204) -async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None: - save = await _find_save(db, user, version_id, slot) - if save: - await db.delete(save) - await db.commit() +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import select + +from app.deps import DB, CurrentUser, has_perm, require +from app.models import GameVersion, Save, User +from app.routers.games import get_game +from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut +from app.uploads import remove_upload, upload_path + +router = APIRouter(prefix="/api", tags=["versions"]) +Player = Annotated[User, Depends(require("games.play"))] +Uploader = Annotated[User, Depends(require("games.upload"))] + +ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz") + + +def can_see(user: User, v: GameVersion) -> bool: + return v.status == "ready" or v.owner_id == user.id or can_manage(user, v) + + +def can_manage(user: User, v: GameVersion) -> bool: + return ( + v.owner_id == user.id + or has_perm(user, "versions.manage_any") + or has_perm(user, "games.delete") + ) + + +async def get_version(db: DB, user: User, version_id: int) -> GameVersion: + v = await db.get(GameVersion, version_id) + if not v or not can_see(user, v): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена") + return v + + +@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201) +async def upload_version( + game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал" +) -> GameVersion: + """Stream a raw archive body to disk and queue it for conversion.""" + game = await get_game(db, game_id) + if not filename.lower().endswith(ARCHIVE_EXTS): + raise HTTPException(status.HTTP_400_BAD_REQUEST, + f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}") + version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал", + upload_name=filename, status="uploading") + db.add(version) + await db.commit() + + path = upload_path(version.id) + path.parent.mkdir(parents=True, exist_ok=True) + size = 0 + try: + with path.open("wb") as f: + async for chunk in request.stream(): + f.write(chunk) + size += len(chunk) + except Exception: + remove_upload(version.id) + await db.delete(version) + await db.commit() + raise + if size == 0: + remove_upload(version.id) + await db.delete(version) + await db.commit() + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл") + + version.status = "queued" + version.progress = "В очереди на конвертацию" + version.size_bytes = size + await db.commit() + return version + + +@router.get("/versions/{version_id}", response_model=VersionDetailOut) +async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion: + return await get_version(db, user, version_id) + + +@router.get("/versions/{version_id}/manifest") +async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]: + v = await get_version(db, user, version_id) + if v.status != "ready" or not v.manifest: + raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") + return v.manifest + + +@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201) +async def fork_version(version_id: int, body: VersionIn, + user: Annotated[User, Depends(require("games.play", "versions.fork"))], + db: DB) -> GameVersion: + parent = await get_version(db, user, version_id) + if parent.status != "ready": + raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию") + fork = GameVersion( + game_id=parent.game_id, + parent_id=parent.id, + owner_id=user.id, + name=body.name.strip(), + description=body.description, + status="ready", + manifest=parent.manifest, + report=parent.report, + size_bytes=parent.size_bytes, + ) + db.add(fork) + await db.commit() + return fork + + +@router.patch("/versions/{version_id}", response_model=VersionOut) +async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion: + v = await get_version(db, user, version_id) + if not can_manage(user, v): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") + v.name = body.name.strip() + v.description = body.description + await db.commit() + return v + + +@router.post("/versions/{version_id}/retry", response_model=VersionOut) +async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion: + v = await get_version(db, user, version_id) + if not can_manage(user, v): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") + if v.status != "failed" or not upload_path(v.id).exists(): + raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна") + v.status = "queued" + v.progress = "В очереди на конвертацию" + await db.commit() + return v + + +@router.delete("/versions/{version_id}", status_code=204) +async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None: + v = await get_version(db, user, version_id) + if not can_manage(user, v): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") + if v.status in ("converting", "importing"): + raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется") + await db.delete(v) + await db.commit() + remove_upload(version_id) + + +# --- saves ----------------------------------------------------------------- + +def _check_slot(slot: str) -> str: + if not slot or len(slot) > 32 or not all(c.isalnum() or c in "_-" for c in slot): + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот") + return slot + + +@router.get("/versions/{version_id}/saves", response_model=list[SaveOut]) +async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]: + await get_version(db, user, version_id) + return list((await db.scalars( + select(Save).where(Save.user_id == user.id, Save.version_id == version_id) + .order_by(Save.updated_at.desc()) + )).all()) + + +async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None: + return await db.scalar(select(Save).where( + Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot))) + + +@router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut) +async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save: + save = await _find_save(db, user, version_id, slot) + if not save: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено") + return save + + +@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut) +async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save: + await get_version(db, user, version_id) + save = await _find_save(db, user, version_id, slot) + if save is None: + save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255], + data=body.data) + db.add(save) + else: + save.title = body.title[:255] + save.data = body.data + await db.commit() + await db.refresh(save) + return save + + +@router.delete("/versions/{version_id}/saves/{slot}", status_code=204) +async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None: + save = await _find_save(db, user, version_id, slot) + if save: + await db.delete(save) + await db.commit() diff --git a/backend/app/worker.py b/backend/app/worker.py index eab6ec5..215ad7b 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -3,6 +3,7 @@ import asyncio import logging import shutil +from pathlib import Path from sqlalchemy import select, update @@ -41,34 +42,39 @@ async def claim() -> GameVersion | None: async def process(v: GameVersion) -> None: - settings = get_settings() + ok = await convert(v.id, upload_path(v.id), v.upload_name or "") + if ok: + remove_upload(v.id) # sources are not kept once converted + + +async def convert(version_id: int, source: Path, filename: str) -> bool: + """Run the converter for a version and store the outcome. Returns success.""" loop = asyncio.get_running_loop() - work = settings.work_dir / str(v.id) + work = get_settings().work_dir / str(version_id) def progress(text: str) -> None: - asyncio.run_coroutine_threadsafe(set_progress(v.id, text), loop) + asyncio.run_coroutine_threadsafe(set_progress(version_id, text), loop) - log.info("converting version %s (%s)", v.id, v.upload_name) + log.info("converting version %s (%s)", version_id, filename) try: - result = await asyncio.to_thread(convert_archive, upload_path(v.id), v.upload_name or "", - work, progress) + result = await asyncio.to_thread(convert_archive, source, filename, work, progress) except Exception as e: # noqa: BLE001 - log.exception("version %s failed", v.id) + log.exception("version %s failed", version_id) async with SessionLocal() as db: - version = await db.get(GameVersion, v.id) + version = await db.get(GameVersion, version_id) if version: version.status = "failed" version.progress = f"Ошибка: {e}"[:255] version.report = {"error": str(e)} await db.commit() - return + return False finally: shutil.rmtree(work, ignore_errors=True) async with SessionLocal() as db: - version = await db.get(GameVersion, v.id) + version = await db.get(GameVersion, version_id) if version is None: # deleted while converting - return + return True version.status = "ready" version.progress = "" version.manifest = result.manifest @@ -81,9 +87,11 @@ async def process(v: GameVersion) -> None: read_bytes(result.cover_source)) except OSError: pass + if game and result.title and game.title == source.name: + game.title = result.title await db.commit() - remove_upload(v.id) # sources are not kept once converted - log.info("version %s ready: %s", v.id, result.report.get("media")) + log.info("version %s ready: %s", version_id, result.report.get("media")) + return True async def main() -> None: diff --git a/backend/tests/test_converter.py b/backend/tests/test_converter.py index 3ed5cd6..08eb3cc 100644 --- a/backend/tests/test_converter.py +++ b/backend/tests/test_converter.py @@ -79,3 +79,44 @@ def test_full_pipeline(data_dir, tmp_path): assert [lbl["name"] for lbl in script["labels"]] == ["start", "walk", "stay_home", "ending"] if shutil.which("ffmpeg"): assert "audio/theme.ogg" in files + + +def test_nested_labels_and_named_menus_become_anchors(): + from app.converter.pipeline import anchors + d = parse("label start:\n if x:\n label inner:\n \"a\"\n" + " menu pick:\n \"A\":\n jump .loc\n" + "label .loc:\n return\n") + labels = d.modules["t.rpy"] + assert [lbl["name"] for lbl in labels] == ["start", "inner.loc"] # local labels follow the last global one + found = anchors(labels) + assert found["inner"] == {"label": "start", "path": [0, 0, 0], "params": None} + assert found["pick"]["path"] == [1] + assert labels[0]["body"][2]["items"][0]["body"][0]["target"] == "inner.loc" + + +def test_call_arguments_and_label_params(): + d = parse('label start:\n call greet("Bob", loud=True) from _c1\n' + 'label greet(name, loud=False):\n "Hi [name]"\n return\n') + call = d.modules["t.rpy"][0]["body"][0] + assert call["target"] == "greet" and call["args"]["a"][0]["v"] == "Bob" + assert "loud" in call["args"]["kw"] + params = d.modules["t.rpy"][1]["params"] + assert params[0] == ["name", None] and params[1][1]["v"] is False + + +def test_atl_frames_layeredimage_and_channels(): + d = parse('image blink:\n "a.png" with dissolve\n pause 0.5\n "b.png"\n 0.25\n' + ' repeat\n' + 'image vid:\n Movie(play="v.webm")\n' + 'layeredimage eileen:\n always:\n "eileen_base"\n' + ' group face:\n attribute happy default\n attribute sad "sad.png"\n' + 'init python:\n renpy.music.register_channel("amb", mixer="sfx", loop=True)\n') + blink = d.images["blink"] + assert [f.get("d") for f in blink["frames"]] == [0.5, 0.25] and blink["repeat"] + assert d.images["vid"] == {"movie": "v.webm"} + layers = d.images["eileen"]["layered"] + assert layers[0] == {"always": {"ref": "eileen_base"}} + assert layers[1] == {"attr": "happy", "group": "face", "default": True, + "img": {"ref": "eileen_face_happy"}} + assert layers[2]["img"] == {"file": "sad.png"} + assert d.channels["amb"] == {"mixer": "sfx", "loop": True} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index ad23761..334ff4f 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,118 +1,118 @@ -export interface Role { - id: number; - name: string; - permissions: string[]; - is_system: boolean; -} - -export interface User { - id: number; - username: string; - role: Role; - is_active: boolean; - must_change_password: boolean; - must_change_username: boolean; - created_at: string; -} - -export interface Me extends User { - permissions: string[]; -} - -export interface Version { - id: number; - game_id: number; - parent_id: number | null; - owner: { id: number; username: string } | null; - name: string; - description: string; - status: "uploading" | "queued" | "converting" | "ready" | "failed"; - progress: string; - size_bytes: number; - created_at: string; - report?: Record | null; -} - -export interface Game { - id: number; - title: string; - description: string; - tags: string[]; - engine: string; - cover_hash: string | null; - created_at: string; -} - -export interface GameDetail extends Game { - versions: Version[]; -} - -export class ApiError extends Error { - constructor(public status: number, message: string) { - super(message); - } -} - -async function request(method: string, url: string, body?: unknown): Promise { - const r = await fetch(url, { - method, - credentials: "same-origin", - headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - if (!r.ok) { - let msg = r.statusText; - try { - const data = await r.json(); - msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); - } catch { - /* not json */ - } - if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized")); - throw new ApiError(r.status, msg); - } - return r.status === 204 ? (undefined as T) : r.json(); -} - -export const api = { - get: (url: string) => request("GET", url), - post: (url: string, body?: unknown) => request("POST", url, body ?? {}), - put: (url: string, body?: unknown) => request("PUT", url, body ?? {}), - patch: (url: string, body?: unknown) => request("PATCH", url, body ?? {}), - del: (url: string) => request("DELETE", url), -}; - -/** Upload a raw body with progress (fetch has no upload progress). */ -export function uploadRaw(method: string, url: string, file: Blob, - onProgress: (fraction: number) => void): Promise { - return new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest(); - xhr.open(method, url); - xhr.setRequestHeader("Content-Type", "application/octet-stream"); - xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); - xhr.onload = () => { - let data: any = null; - try { - data = JSON.parse(xhr.responseText); - } catch { - /* empty */ - } - if (xhr.status >= 200 && xhr.status < 300) resolve(data); - else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText)); - }; - xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); - xhr.send(file); - }); -} - -export function formatSize(bytes: number): string { - if (bytes < 1024) return `${bytes} Б`; - const units = ["КБ", "МБ", "ГБ", "ТБ"]; - let v = bytes / 1024; - let i = 0; - while (v >= 1024 && i < units.length - 1) { - v /= 1024; - i++; - } - return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; -} +export interface Role { + id: number; + name: string; + permissions: string[]; + is_system: boolean; +} + +export interface User { + id: number; + username: string; + role: Role; + is_active: boolean; + must_change_password: boolean; + must_change_username: boolean; + created_at: string; +} + +export interface Me extends User { + permissions: string[]; +} + +export interface Version { + id: number; + game_id: number; + parent_id: number | null; + owner: { id: number; username: string } | null; + name: string; + description: string; + status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed"; + progress: string; + size_bytes: number; + created_at: string; + report?: Record | null; +} + +export interface Game { + id: number; + title: string; + description: string; + tags: string[]; + engine: string; + cover_hash: string | null; + created_at: string; +} + +export interface GameDetail extends Game { + versions: Version[]; +} + +export class ApiError extends Error { + constructor(public status: number, message: string) { + super(message); + } +} + +async function request(method: string, url: string, body?: unknown): Promise { + const r = await fetch(url, { + method, + credentials: "same-origin", + headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!r.ok) { + let msg = r.statusText; + try { + const data = await r.json(); + msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); + } catch { + /* not json */ + } + if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized")); + throw new ApiError(r.status, msg); + } + return r.status === 204 ? (undefined as T) : r.json(); +} + +export const api = { + get: (url: string) => request("GET", url), + post: (url: string, body?: unknown) => request("POST", url, body ?? {}), + put: (url: string, body?: unknown) => request("PUT", url, body ?? {}), + patch: (url: string, body?: unknown) => request("PATCH", url, body ?? {}), + del: (url: string) => request("DELETE", url), +}; + +/** Upload a raw body with progress (fetch has no upload progress). */ +export function uploadRaw(method: string, url: string, file: Blob, + onProgress: (fraction: number) => void): Promise { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); + xhr.onload = () => { + let data: any = null; + try { + data = JSON.parse(xhr.responseText); + } catch { + /* empty */ + } + if (xhr.status >= 200 && xhr.status < 300) resolve(data); + else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText)); + }; + xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); + xhr.send(file); + }); +} + +export function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} Б`; + const units = ["КБ", "МБ", "ГБ", "ТБ"]; + let v = bytes / 1024; + let i = 0; + while (v >= 1024 && i < units.length - 1) { + v /= 1024; + i++; + } + return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; +} diff --git a/frontend/src/pages/GamePage.tsx b/frontend/src/pages/GamePage.tsx index a55c899..7b6a789 100644 --- a/frontend/src/pages/GamePage.tsx +++ b/frontend/src/pages/GamePage.tsx @@ -7,6 +7,7 @@ const STATUS: Record = { uploading: "загружается", queued: "в очереди", converting: "конвертируется", + importing: "импортируется", ready: "готова", failed: "ошибка", }; @@ -32,7 +33,7 @@ export function GamePage() { }, [load]); // poll while something is being converted - const busy = game?.versions.some((v) => ["uploading", "queued", "converting"].includes(v.status)); + const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status)); useEffect(() => { if (!busy) return; const t = setInterval(() => void load(), 2000); @@ -154,7 +155,7 @@ function VersionRow(props: { {v.status === "ready" && props.canFork && } {(v.status === "ready" || v.status === "failed") && } {v.status === "failed" && props.canManage && } - {props.canManage && v.status !== "converting" && } + {props.canManage && !["converting", "importing"].includes(v.status) && } {report !== undefined && } diff --git a/frontend/src/player/audio.ts b/frontend/src/player/audio.ts index ca260a2..643a198 100644 --- a/frontend/src/player/audio.ts +++ b/frontend/src/player/audio.ts @@ -1,124 +1,126 @@ -// Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots. - -export interface Volumes { - music: number; - sound: number; - voice: number; -} - -interface Channel { - el: HTMLAudioElement | null; - file: string | null; - loop: boolean; - queue: string[]; -} - -function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) { - if (seconds <= 0) { - el.volume = to; - done?.(); - return; - } - const start = performance.now(); - const step = () => { - const t = Math.min(1, (performance.now() - start) / (seconds * 1000)); - el.volume = Math.max(0, Math.min(1, from + (to - from) * t)); - if (t < 1) requestAnimationFrame(step); - else done?.(); - }; - requestAnimationFrame(step); -} - -export class AudioSystem { - channels = new Map(); - - constructor(public volumes: Volumes, private url: (file: string) => string | null) {} - - private group(ch: string): keyof Volumes { - if (ch === "music") return "music"; - if (ch === "voice") return "voice"; - return "sound"; - } - - private channel(name: string): Channel { - let c = this.channels.get(name); - if (!c) { - c = { el: null, file: null, loop: false, queue: [] }; - this.channels.set(name, c); - } - return c; - } - - play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number; - ifChanged?: boolean }) { - const c = this.channel(ch); - const loop = opts.loop ?? ch === "music"; - if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return; - this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0)); - c.queue = files.slice(1); - c.loop = loop; - this.start(ch, c, files[0], opts.fadein ?? 0); - } - - queue(ch: string, files: string[], loop?: boolean | null) { - const c = this.channel(ch); - if (!c.el || c.el.paused) { - this.play(ch, files, { loop }); - return; - } - c.queue.push(...files); - if (loop != null) c.loop = loop; - } - - private start(ch: string, c: Channel, file: string | undefined, fadein: number) { - if (!file) return; - const src = this.url(file); - if (!src) { - console.warn("[pgvn] нет аудиофайла", file); - return; - } - const el = new Audio(src); - const target = this.volumes[this.group(ch)]; - el.volume = fadein > 0 ? 0 : target; - el.loop = c.loop && c.queue.length === 0; - el.onended = () => { - if (c.el !== el) return; - const next = c.queue.shift(); - if (next) { - if (c.loop) c.queue.push(next); - this.start(ch, c, next, 0); - } else { - c.el = null; - c.file = null; - } - }; - c.el = el; - c.file = file; - el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука")); - if (fadein > 0) fade(el, 0, target, fadein); - } - - stop(ch: string, fadeout = 0) { - const c = this.channels.get(ch); - if (!c?.el) return; - const el = c.el; - c.el = null; - c.file = null; - c.queue = []; - fade(el, el.volume, 0, fadeout, () => el.pause()); - } - - stopAll() { - for (const ch of this.channels.keys()) this.stop(ch); - } - - setVolumes(v: Volumes) { - this.volumes = v; - for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)]; - } - - current(ch: string): { file: string; loop: boolean } | null { - const c = this.channels.get(ch); - return c?.file ? { file: c.file, loop: c.loop } : null; - } -} +// Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots. + +export interface Volumes { + music: number; + sound: number; + voice: number; +} + +interface Channel { + el: HTMLAudioElement | null; + file: string | null; + loop: boolean; + queue: string[]; +} + +function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) { + if (seconds <= 0) { + el.volume = to; + done?.(); + return; + } + const start = performance.now(); + const step = () => { + const t = Math.min(1, (performance.now() - start) / (seconds * 1000)); + el.volume = Math.max(0, Math.min(1, from + (to - from) * t)); + if (t < 1) requestAnimationFrame(step); + else done?.(); + }; + requestAnimationFrame(step); +} + +export class AudioSystem { + channels = new Map(); + + constructor(public volumes: Volumes, private url: (file: string) => string | null, + private registered: Record = {}) {} + + private group(ch: string): keyof Volumes { + const mixer = this.registered[ch]?.mixer ?? ch; + if (mixer === "music") return "music"; + if (mixer === "voice") return "voice"; + return "sound"; + } + + private channel(name: string): Channel { + let c = this.channels.get(name); + if (!c) { + c = { el: null, file: null, loop: false, queue: [] }; + this.channels.set(name, c); + } + return c; + } + + play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number; + ifChanged?: boolean }) { + const c = this.channel(ch); + const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music"; + if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return; + this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0)); + c.queue = files.slice(1); + c.loop = loop; + this.start(ch, c, files[0], opts.fadein ?? 0); + } + + queue(ch: string, files: string[], loop?: boolean | null) { + const c = this.channel(ch); + if (!c.el || c.el.paused) { + this.play(ch, files, { loop }); + return; + } + c.queue.push(...files); + if (loop != null) c.loop = loop; + } + + private start(ch: string, c: Channel, file: string | undefined, fadein: number) { + if (!file) return; + const src = this.url(file); + if (!src) { + console.warn("[pgvn] нет аудиофайла", file); + return; + } + const el = new Audio(src); + const target = this.volumes[this.group(ch)]; + el.volume = fadein > 0 ? 0 : target; + el.loop = c.loop && c.queue.length === 0; + el.onended = () => { + if (c.el !== el) return; + const next = c.queue.shift(); + if (next) { + if (c.loop) c.queue.push(next); + this.start(ch, c, next, 0); + } else { + c.el = null; + c.file = null; + } + }; + c.el = el; + c.file = file; + el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука")); + if (fadein > 0) fade(el, 0, target, fadein); + } + + stop(ch: string, fadeout = 0) { + const c = this.channels.get(ch); + if (!c?.el) return; + const el = c.el; + c.el = null; + c.file = null; + c.queue = []; + fade(el, el.volume, 0, fadeout, () => el.pause()); + } + + stopAll() { + for (const ch of this.channels.keys()) this.stop(ch); + } + + setVolumes(v: Volumes) { + this.volumes = v; + for (const [name, c] of this.channels) if (c.el) c.el.volume = v[this.group(name)]; + } + + current(ch: string): { file: string; loop: boolean } | null { + const c = this.channels.get(ch); + return c?.file ? { file: c.file, loop: c.loop } : null; + } +} diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index 02501a9..0ae4445 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -1,1289 +1,1357 @@ -// PGVN interpreter + in-game UI. - -import { AudioSystem, type Volumes } from "./audio"; -import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python"; -import { Renderer, type ResolvedImage } from "./renderer"; -import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; -import type { - CharacterDef, Expr, Frame, GameData, ImageDef, Label, Manifest, Node, SaveData, ScriptModule, -} from "./types"; - -export interface SaveMeta { - slot: string; - title: string; - updated_at: string; -} - -export interface SaveApi { - list(): Promise; - get(slot: string): Promise; - put(slot: string, title: string, data: SaveData): Promise; - remove(slot: string): Promise; -} - -interface Settings extends Volumes { - cps: number; // characters per second, 0 = instant - autoDelay: number; // seconds per 20 characters in auto mode -} - -const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 }; -const SLOTS = 12; - -interface LabelRef { - label: Label; - module: ScriptModule; - index: number; -} - -class Restart extends Error {} - -function el(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] { - const e = document.createElement(tag); - if (cls) e.className = cls; - if (text) e.textContent = text; - return e; -} - -function loadJSON(key: string, fallback: T): T { - try { - const raw = localStorage.getItem(key); - return raw ? { ...fallback, ...JSON.parse(raw) } : fallback; - } catch { - return fallback; - } -} - -function saveJSON(key: string, value: unknown) { - try { - localStorage.setItem(key, JSON.stringify(value)); - } catch { - /* storage unavailable */ - } -} - -export class Engine { - private labels = new Map(); - private imagesByTag = new Map(); - private store: Record = {}; - private builtins: Record = {}; - private persistent: Record; - private py: Python; - private renderer: Renderer; - private audio: AudioSystem; - private settings: Settings; - - private cur: Frame = { label: "", path: [0] }; - private stack: Frame[] = []; - private gen = 0; - private waiters: (() => void)[] = []; - private running = false; - - private skip = false; - private auto = false; - private windowShown = true; - private lastSay: { name: string; text: string; color?: string; char?: CharacterDef } | null = null; - private history: { name: string; text: string }[] = []; - private nvlLines: HTMLElement[] = []; - private interactions = 0; - - // UI - private container: HTMLDivElement; - private textbox!: HTMLDivElement; - private namebox!: HTMLDivElement; - private textEl!: HTMLDivElement; - private ctc!: HTMLSpanElement; - private menuEl!: HTMLDivElement; - private nvlEl!: HTMLDivElement; - private centeredEl!: HTMLDivElement; - private quick!: HTMLDivElement; - private screen!: HTMLDivElement; - private notifyEl!: HTMLDivElement; - private baseSize: number; - private resizeObserver: ResizeObserver; - - constructor( - private root: HTMLElement, - private game: GameData, - private manifest: Manifest, - modules: ScriptModule[], - private saves: SaveApi, - private persistentKey: string, - private onExit: () => void, - ) { - for (const mod of modules) { - let global = ""; - mod.labels.forEach((label, index) => { - if (label.name.startsWith(".")) label.name = global + label.name; - else global = label.name.split(".")[0]; - this.labels.set(label.name, { label, module: mod, index }); - }); - } - for (const key of Object.keys(game.images)) { - const [tag, ...attrs] = key.split(" "); - if (!this.imagesByTag.has(tag)) this.imagesByTag.set(tag, []); - this.imagesByTag.get(tag)!.push({ attrs, key }); - } - - this.settings = loadJSON("pgvn:settings", DEFAULT_SETTINGS); - this.persistent = this.makePersistent(); - const scope: Scope = { - get: (n) => (n in this.store ? this.store[n] : this.builtins[n]), - has: (n) => n in this.store || n in this.builtins, - set: (n, v) => void (this.store[n] = v), - del: (n) => void delete this.store[n], - }; - this.py = new Python(scope); - this.builtins = this.makeBuiltins(); - - const [w, h] = game.resolution; - this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306); - this.container = el("div", "pg-root"); - root.append(this.container); - this.renderer = new Renderer(this.container, w, h, { - url: (f) => this.fileUrl(f), - resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null, - evalCond: (c) => truthy(this.safeEval(c)), - transform: (name) => game.transforms[name] ?? null, - }); - this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f)); - this.buildUI(); - this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container)); - this.resizeObserver.observe(this.container); - this.renderer.fit(this.container); - window.addEventListener("keydown", this.onKey); - window.addEventListener("keyup", this.onKeyUp); - } - - destroy() { - this.gen++; - this.wakeAll(); - this.audio.stopAll(); - this.resizeObserver.disconnect(); - window.removeEventListener("keydown", this.onKey); - window.removeEventListener("keyup", this.onKeyUp); - this.container.remove(); - } - - // --- files & images ---------------------------------------------------------- - - fileUrl(path: string): string | null { - const clean = path.replace(/^<[^>]*>/, "").replace(/\\/g, "/").replace(/^\/+/, ""); - let logical: string | undefined = this.manifest.files[clean] ? clean : undefined; - if (!logical) { - const low = clean.toLowerCase(); - for (const prefix of ["", "images/", "audio/", "music/", "sound/", "sounds/"]) { - logical = this.game.files[prefix + low]; - if (logical) break; - } - } - const entry = logical ? this.manifest.files[logical] : undefined; - return entry ? `/blobs/${entry.hash}` : null; - } - - private resolveImage(name: string[]): ResolvedImage | null { - const full = name.join(" "); - if (this.game.images[full]) return { def: this.game.images[full], name }; - const [tag, ...attrs] = name; - const candidates = this.imagesByTag.get(tag) ?? []; - let best: { attrs: string[]; key: string } | null = null; - for (const c of candidates) { - if (attrs.every((a) => c.attrs.includes(a)) && (!best || c.attrs.length < best.attrs.length)) best = c; - } - if (!best && attrs.length) { - // keep attributes of the currently shown image that the new ones don't replace - const current = this.renderer.attrsOf(tag)?.slice(1) ?? []; - for (const c of candidates) { - if (attrs.every((a) => c.attrs.includes(a)) || !c.attrs.some((a) => attrs.includes(a))) continue; - const keeps = current.filter((a) => c.attrs.includes(a)).length; - if (!best || keeps > 0) best = c; - } - } - if (!best) return null; - return { def: this.game.images[best.key], name: best.key.split(" ") }; - } - - private imageFromSpec(spec: any): ResolvedImage | null { - if (spec.name) return this.resolveImage(spec.name); - if (spec.text !== undefined) return { def: { text: spec.text }, name: ["text"] }; - if (spec.expr) { - const v = this.safeEval(spec.expr); - if (typeof v !== "string") return null; - if (v.startsWith("#")) return { def: { color: v }, name: [v] }; - if (v.includes(".")) return { def: { file: v }, name: [v.split("/").pop()!.split(".")[0]] }; - return this.resolveImage(v.split(" ")); - } - return null; - } - - // --- python environment ---------------------------------------------------------- - - private safeEval(e: Expr | null | undefined): any { - try { - return this.py.eval(e); - } catch (err) { - if (err instanceof JumpSignal) throw err; - console.warn("[pgvn] ошибка выражения", err); - return null; - } - } - - private makePersistent(): Record { - const data = decodeValue(loadJSON>(this.persistentKey, {})); - const save = () => saveJSON(this.persistentKey, encodeValue(data)); - return new Proxy(data, { - get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined : null), - set: (t, k: string, v) => { - t[k] = v; - save(); - return true; - }, - }); - } - - private makeBuiltins(): Record { - const kwless = (args: any[]) => - args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args; - const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {}; - const random = { - randint: (a: number, b: number) => a + Math.floor(Math.random() * (b - a + 1)), - random: () => Math.random(), - choice: (xs: any[]) => xs[Math.floor(Math.random() * xs.length)], - shuffle: (xs: any[]) => { - for (let i = xs.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); - [xs[i], xs[j]] = [xs[j], xs[i]]; - } - }, - uniform: (a: number, b: number) => a + Math.random() * (b - a), - }; - const music = (ch: string) => ({ - play: (...a: any[]) => { - const kw = kwOf(a); - const files = kwless(a)[0]; - this.audio.play(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String), - { loop: kw.loop, fadein: kw.fadein, fadeout: kw.fadeout }); - }, - queue: (...a: any[]) => { - const kw = kwOf(a); - const files = kwless(a)[0]; - this.audio.queue(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String), kw.loop); - }, - stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0), - set_volume: () => {}, - get_playing: () => this.audio.current(ch)?.file ?? null, - is_playing: () => this.audio.current(ch) !== null, - }); - const renpyImpl: Record = { - random, - music: music("music"), - sound: music("sound"), - play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}), - notify: (msg: any) => this.notify(pyStr(msg)), - jump: (label: string) => { - throw new JumpSignal("jump", label); - }, - call: (label: string) => { - throw new JumpSignal("call", label); - }, - input: (...a: any[]) => { - const [prompt, def] = kwless(a); - const kw = kwOf(a); - const v = window.prompt(pyStr(prompt ?? ""), pyStr(def ?? kw.default ?? "")); - return (v ?? pyStr(def ?? kw.default ?? "")).slice(0, kw.length ?? 1000); - }, - has_label: (l: string) => this.labels.has(l), - seen_label: (l: string) => truthy(this.persistent._seen?.includes?.(l)), - showing: (tag: string) => this.renderer.isShown(String(tag).split(" ")[0]), - get_attributes: (tag: string) => this.renderer.attrsOf(tag)?.slice(1) ?? [], - full_restart: () => { - throw new JumpSignal("jump", "__main_menu__"); - }, - quit: () => { - throw new JumpSignal("jump", "__quit__"); - }, - version: () => "pgvn", - }; - const renpy = new Proxy(renpyImpl, { - get: (t, k: string) => { - if (k in t) return t[k]; - this.py.warnOnce(`renpy.${k} не поддерживается — пропущено`); - return () => null; - }, - }); - const num = (x: any) => (typeof x === "string" ? Number(x.trim()) : Number(x)); - return { - renpy, - persistent: this.persistent, - config: {}, - preferences: {}, - _preferences: {}, - gui: {}, - store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null, - set: (_t, k: string, v) => ((this.store[k] = v), true) }), - audio: this.game.audio, - random, - math: { floor: Math.floor, ceil: Math.ceil, sqrt: Math.sqrt, pi: Math.PI, pow: Math.pow }, - len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0), - str: (x: any) => pyStr(x), - int: (x: any) => Math.trunc(num(x)), - float: (x: any) => num(x), - bool: (x: any) => truthy(x), - abs: Math.abs, - round: (x: number, n = 0) => Math.round(x * 10 ** n) / 10 ** n, - min: (...a: any[]) => (a.length === 1 ? Math.min(...a[0]) : Math.min(...a)), - max: (...a: any[]) => (a.length === 1 ? Math.max(...a[0]) : Math.max(...a)), - sum: (xs: number[]) => xs.reduce((s, x) => s + x, 0), - any: (xs: any[]) => xs.some(truthy), - all: (xs: any[]) => xs.every(truthy), - range: (a: number, b?: number, step = 1) => { - const [lo, hi] = b === undefined ? [0, a] : [a, b]; - const out: number[] = []; - for (let i = lo; step > 0 ? i < hi : i > hi; i += step) out.push(i); - return out; - }, - list: (xs: any = []) => [...(xs instanceof PyDict ? xs.map.keys() : xs)], - tuple: (xs: any = []) => [...xs], - set: (xs: any = []) => [...new Set(xs)], - dict: (...a: any[]) => { - const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []); - for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v); - return d; - }, - sorted: (xs: any[]) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), - reversed: (xs: any[]) => [...xs].reverse(), - enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]), - zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])), - isinstance: () => false, - _: (s: string) => s, - __: (s: string) => s, - }; - } - - private resetStore() { - this.store = {}; - try { - this.py.exec(this.game.init); - } catch (err) { - console.warn("[pgvn] ошибка init", err); - } - } - - private newStore() { - this.resetStore(); - for (const [name, e] of this.game.defaults) { - if (!(name in this.store)) this.store[name] = this.safeEval(e); - } - } - - // --- control flow ------------------------------------------------------------------ - - private blockFor(frame: Frame): Node[] { - let block = this.labels.get(frame.label)!.label.body; - for (let k = 0; k < frame.path.length - 1; k += 2) { - block = childBlock(block[frame.path[k]], frame.path[k + 1]); - } - return block; - } - - private node(): Node | null { - if (!this.labels.has(this.cur.label)) return null; - return this.blockFor(this.cur)[this.cur.path[this.cur.path.length - 1]] ?? null; - } - - /** After a path change, move past ended blocks (falling through labels / looping whiles). */ - private settle() { - for (let guard = 0; guard < 10000; guard++) { - const p = this.cur.path; - if (!this.labels.has(this.cur.label)) return; - const block = this.blockFor(this.cur); - if (p[p.length - 1] < block.length) return; - if (p.length === 1) { - const ref = this.labels.get(this.cur.label)!; - const next = ref.module.labels[ref.index + 1]; - if (next) { - this.cur = { label: next.name, path: [0] }; - continue; - } - this.doReturn(); - return; - } - const parent = p.slice(0, -2); - const parentNode = this.blockFor({ label: this.cur.label, path: parent })[parent[parent.length - 1]]; - this.cur.path = parent; - if (parentNode.op !== "while") this.cur.path[parent.length - 1]++; - } - } - - private advance() { - this.cur.path[this.cur.path.length - 1]++; - this.settle(); - } - - private enter(branch: number) { - this.cur.path.push(branch, 0); - this.settle(); - } - - private jump(target: string) { - if (target === "__main_menu__" || target === "__quit__") { - throw new Restart(target); - } - let name = target; - if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name; - if (!this.labels.has(name)) { - this.notify(`Метка «${target}» не найдена`); - console.warn("[pgvn] нет метки", target); - this.advance(); - return; - } - this.cur = { label: name, path: [0] }; - this.markSeen(name); - this.settle(); - } - - private call(target: string) { - this.stack.push({ label: this.cur.label, path: [...this.cur.path] }); - this.jump(target); - } - - private doReturn() { - const frame = this.stack.pop(); - if (!frame) { - this.cur = { label: "", path: [0] }; - return; - } - this.cur = frame; - this.advance(); - } - - private markSeen(label: string) { - const seen: string[] = this.persistent._seen ?? []; - if (!seen.includes(label)) this.persistent._seen = [...seen, label]; - } - - // --- main loop -------------------------------------------------------------------- - - private wait(setup: (done: (v: T) => void) => void): Promise { - const gen = this.gen; - return new Promise((resolve, reject) => { - const wake = () => reject(new Restart()); - this.waiters.push(wake); - setup((v) => { - this.waiters = this.waiters.filter((w) => w !== wake); - if (gen === this.gen) resolve(v); - else reject(new Restart()); - }); - }); - } - - private wakeAll() { - const ws = this.waiters; - this.waiters = []; - ws.forEach((w) => w()); - } - - private async loop() { - const gen = this.gen; - this.running = true; - try { - while (gen === this.gen) { - if (!this.cur.label) { - this.showMainMenu(); - return; - } - const node = this.node(); - if (!node) { - this.settle(); - if (!this.node()) { - this.cur = { label: "", path: [0] }; - } - continue; - } - try { - await this.exec(node); - } catch (err) { - if (err instanceof JumpSignal) { - if (err.kind === "jump") this.jump(err.target); - else this.call(err.target); - } else throw err; - } - } - } catch (err) { - if (err instanceof Restart) { - if (err.message === "__main_menu__") this.showMainMenu(); - if (err.message === "__quit__") this.onExit(); - return; - } - console.error(err); - this.notify(`Ошибка плеера: ${(err as Error).message}`); - } finally { - if (gen === this.gen) this.running = false; - } - } - - private async exec(node: Node) { - switch (node.op) { - case "say": - await this.say(node); - this.advance(); - return; - case "scene": - case "show": - case "hide": { - if (node.with) this.renderer.commit(); - if (node.op === "scene") { - this.renderer.scene(); - this.clearNvl(); - } - if (node.op === "hide") this.renderer.hide(node.tag); - else if (node.img) this.showNode(node); - if (node.with) await this.renderer.transition(this.transition(node.with)); - this.advance(); - return; - } - case "with": - await this.renderer.transition(this.transition(node.t)); - this.advance(); - return; - case "play": - case "queue": { - const files = node.files.map((f: Expr) => this.audioFile(f)).filter(Boolean) as string[]; - if (files.length) { - if (node.op === "play") - this.audio.play(node.ch, files, { loop: node.loop, fadein: node.fadein, fadeout: node.fadeout, - ifChanged: node.ifChanged }); - else this.audio.queue(node.ch, files, node.loop); - } - this.advance(); - return; - } - case "stop": - this.audio.stop(node.ch, node.fadeout ?? 0); - this.advance(); - return; - case "voice": - if (node.file) { - const f = this.audioFile(node.file); - if (f) this.audio.play("voice", [f], { loop: false }); - } - this.advance(); - return; - case "pause": { - const d = node.d ? Number(this.safeEval(node.d)) : null; - await this.pause(d); - this.advance(); - return; - } - case "menu": - return this.menu(node); - case "if": { - const i = node.branches.findIndex((b: any) => b.c === null || truthy(this.safeEval(b.c))); - if (i >= 0) this.enter(i); - else this.advance(); - return; - } - case "while": - if (truthy(this.safeEval(node.c))) this.enter(0); - else this.advance(); - return; - case "jump": - return this.jump(node.target ?? pyStr(this.safeEval(node.expr))); - case "call": - return this.call(node.target ?? pyStr(this.safeEval(node.expr))); - case "return": - this.doReturn(); - return; - case "exec": - try { - this.py.exec(node.code); - } catch (err) { - if (err instanceof JumpSignal) throw err; - console.warn("[pgvn] ошибка python", err, node.code); - } - this.advance(); - return; - case "window": - this.windowShown = node.mode !== "hide"; - if (!this.windowShown) this.textbox.classList.add("pg-hidden"); - this.advance(); - return; - case "nvl": - if (node.a === "clear") this.clearNvl(); - else this.nvlEl.classList.toggle("pg-hidden", node.a === "hide"); - this.advance(); - return; - default: - this.advance(); - } - } - - private showNode(node: Node) { - const img = this.imageFromSpec(node.img); - if (!img) { - console.warn("[pgvn] нет изображения", node.img); - return; - } - const tag = node.as ?? (node.img.name ? node.img.name[0] : img.name[0]); - const zorder = node.zorder ? Number(this.safeEval(node.zorder)) : undefined; - this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind }); - } - - private transition(t: any) { - if (t && t.t === "ref") return this.game.transitions[t.name] ?? { t: "dissolve", d: 0.5 }; - return t; - } - - private audioFile(e: Expr): string | null { - if (e.k === "name" && this.game.audio[e.id] && !(e.id in this.store)) return this.game.audio[e.id]; - const v = this.safeEval(e); - if (typeof v !== "string") return null; - return this.game.audio[v] ?? v; - } - - // --- interactions ----------------------------------------------------------------- - - private interactionStart() { - this.renderer.commit(); - this.interactions++; - if (this.interactions % 15 === 0) void this.save("auto", true); - this.preloadAhead(); - } - - private preloadAhead() { - const block = this.labels.has(this.cur.label) ? this.blockFor(this.cur) : []; - const start = this.cur.path[this.cur.path.length - 1]; - for (const n of block.slice(start + 1, start + 12)) { - if ((n.op === "show" || n.op === "scene") && n.img?.name) this.renderer.preload(this.resolveImage(n.img.name)?.def ?? null); - } - } - - private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } { - const who: string | undefined = node.who; - if (node.whoStr !== undefined) return { char: {}, name: node.whoStr, mode: "adv" }; - if (!who || who === "narrator") return { char: {}, name: "", mode: "adv" }; - if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" }; - if (who === "extend") return { char: this.lastSay?.char ?? {}, name: this.lastSay?.name ?? "", mode: "extend" }; - if (who === "nvl") return { char: { nvl: true }, name: "", mode: "nvl" }; - const char = this.game.characters[who]; - if (!char) { - const v = this.store[who]; - return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" }; - } - let name = char.name ?? ""; - if (char.nameExpr) name = pyStr(this.safeEval(char.nameExpr)); - name = this.interp(name); - return { char, name: `${char.whoPrefix ?? ""}${name}${char.whoSuffix ?? ""}`, mode: char.nvl ? "nvl" : "adv" }; - } - - private interp(s: string): string { - return interpolate(s, (expr) => this.lookupPath(expr)); - } - - private lookupPath(expr: string): any { - const m = expr.match(/^[A-Za-z_][\w]*/); - if (!m) return ""; - let v = this.py.lookup(m[0]); - const rest = expr.slice(m[0].length); - for (const part of rest.matchAll(/\.(\w+)|\[(\d+|'[^']*'|"[^"]*")\]/g)) { - if (v === null || v === undefined) return ""; - const k = part[1] ?? JSON.parse(part[2].replace(/^'|'$/g, '"')); - v = v instanceof PyDict ? v.get(k) : v[k]; - } - return v ?? ""; - } - - private async say(node: Node, inMenu = false) { - const { char, name, mode } = this.characterFor(node); - if (node.attrs && char.image && this.renderer.isShown(char.image)) { - const img = this.resolveImage([char.image, ...node.attrs]); - if (img) this.renderer.show(img, char.image, []); - } - if (!inMenu) this.interactionStart(); - if (node.with) await this.renderer.transition(this.transition(node.with)); - - let raw = this.interp(node.what); - raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`; - let prefixLen = 0; - if (mode === "extend" && this.lastSay) { - prefixLen = [...plainText(parseText(this.lastSay.text))].length; - raw = this.lastSay.text + raw; - } - const parsed = parseText(raw); - this.lastSay = { name, text: raw, color: char.color, char }; - if (mode !== "extend") this.history.push({ name, text: plainText(parsed) }); - else if (this.history.length) this.history[this.history.length - 1].text = plainText(parsed); - if (this.history.length > 300) this.history.shift(); - - let target: HTMLElement; - if (mode === "centered") { - this.textbox.classList.add("pg-hidden"); - this.centeredEl.classList.remove("pg-hidden"); - target = this.centeredEl; - } else if (mode === "nvl") { - this.textbox.classList.add("pg-hidden"); - this.nvlEl.classList.remove("pg-hidden"); - const line = el("div", "pg-nvl-line"); - if (name) { - const who = el("span", "pg-nvl-name", name); - if (char.color) who.style.color = char.color; - line.append(who); - } - const what = el("span", "pg-nvl-what"); - line.append(what); - this.nvlEl.append(line); - this.nvlLines.push(line); - target = what; - } else { - this.centeredEl.classList.add("pg-hidden"); - this.nvlEl.classList.add("pg-hidden"); - this.textbox.classList.remove("pg-hidden"); - this.namebox.textContent = name; - this.namebox.classList.toggle("pg-hidden", !name); - this.namebox.style.color = char.color ?? ""; - this.textEl.style.color = char.whatColor ?? ""; - this.textEl.style.fontStyle = char.whatItalic ? "italic" : ""; - this.textEl.style.fontWeight = char.whatBold ? "bold" : ""; - target = this.textEl; - } - - await this.typewrite(target, parsed, Math.max(prefixLen, fastPoint(parsed)), inMenu); - if (inMenu || node.interact === false) return; - if (!parsed.noWait) await this.waitAdvance(parsed.length); - this.audio.stop("voice"); - this.centeredEl.classList.add("pg-hidden"); - } - - private async typewrite(target: HTMLElement, parsed: ReturnType, from: number, instant = false) { - this.ctc.remove(); - const total = parsed.length; - const waits = waitPoints(parsed).filter((w) => w.at > from); - let shown = from; - const cps = this.settings.cps; - if (instant || cps <= 0 || this.skip) { - renderText(target, parsed, total, this.baseSize); - target.append(this.ctc); - return; - } - let completed = false; - const finishClick = () => { - completed = true; - }; - this.onAdvance = finishClick; - try { - for (const stop of [...waits.map((w) => w.at), total]) { - let last = performance.now(); - while (shown < stop && !completed && !this.skip) { - await this.frame(); - const now = performance.now(); - shown = Math.min(stop, shown + ((now - last) / 1000) * cps); - last = now; - renderText(target, parsed, Math.floor(shown), this.baseSize); - } - shown = completed || this.skip ? total : stop; - renderText(target, parsed, Math.floor(shown), this.baseSize); - if (completed || this.skip) break; - const w = waits.find((x) => x.at === stop); - if (w) { - if (w.seconds !== null) await this.pause(w.seconds); - else await this.waitAdvance(0); - this.onAdvance = finishClick; - } - } - } finally { - this.onAdvance = null; - } - target.append(this.ctc); - } - - private frame(): Promise { - return this.wait((done) => requestAnimationFrame(() => done())); - } - - private onAdvance: (() => void) | null = null; - - /** Wait for the player to advance (click/space), or skip/auto. */ - private waitAdvance(chars: number): Promise { - return this.wait((done) => { - let timer = 0; - const finish = () => { - clearTimeout(timer); - this.onAdvance = null; - this.skipTick = null; - done(); - }; - this.onAdvance = finish; - if (this.skip) timer = window.setTimeout(finish, 40); - else if (this.auto) timer = window.setTimeout(finish, (1 + chars / 20) * this.settings.autoDelay * 1000); - this.skipTick = () => { - clearTimeout(timer); - if (this.skip) timer = window.setTimeout(finish, 40); - else if (this.auto) timer = window.setTimeout(finish, 1000 * this.settings.autoDelay); - }; - }); - } - - private skipTick: (() => void) | null = null; - - private pause(seconds: number | null): Promise { - if (seconds === null) { - this.interactionStart(); - return this.waitAdvance(0); - } - if (this.skip) return Promise.resolve(); - return this.wait((done) => { - const timer = window.setTimeout(() => { - this.onAdvance = null; - done(); - }, seconds * 1000); - this.onAdvance = () => { - clearTimeout(timer); - this.onAdvance = null; - done(); - }; - }); - } - - private async menu(node: Node) { - this.interactionStart(); - const chosen: string[] = node.set ? this.safeEval(node.set) ?? [] : []; - if (node.say) await this.say(node.say, true); - else if (!this.windowShown) this.textbox.classList.add("pg-hidden"); - const items = node.items - .map((item: any, i: number) => ({ ...item, i })) - .filter((item: any) => (item.if === null || truthy(this.safeEval(item.if))) && !chosen.includes(item.text)); - if (!items.length) { - this.advance(); - return; - } - this.setSkip(false); - const index = await this.wait((done) => { - this.menuEl.textContent = ""; - for (const item of items) { - const b = el("button", "pg-choice"); - renderText(b, parseText(this.interp(item.text)), Infinity, this.baseSize); - b.onclick = (ev) => { - ev.stopPropagation(); - done(item.i); - }; - this.menuEl.append(b); - } - this.menuEl.classList.remove("pg-hidden"); - }); - this.menuEl.classList.add("pg-hidden"); - if (Array.isArray(chosen) && node.set) chosen.push(node.items[index].text); - this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(node.items[index].text)))}` }); - this.enter(index); - } - - private clearNvl() { - this.nvlLines.forEach((l) => l.remove()); - this.nvlLines = []; - this.nvlEl.classList.add("pg-hidden"); - } - - private notify(msg: string) { - this.notifyEl.textContent = msg; - this.notifyEl.classList.remove("pg-hidden"); - window.setTimeout(() => this.notifyEl.classList.add("pg-hidden"), 3000); - } - - // --- session control -------------------------------------------------------------- - - private restart(setup: () => void) { - this.gen++; - this.wakeAll(); - this.onAdvance = null; - this.skipTick = null; - this.closeScreen(); - this.menuEl.classList.add("pg-hidden"); - this.centeredEl.classList.add("pg-hidden"); - this.clearNvl(); - this.setSkip(false); - setup(); - void this.loop(); - } - - start() { - this.showMainMenu(); - } - - private newGame() { - this.restart(() => { - this.audio.stopAll(); - this.renderer.scene(); - this.renderer.commit(); - this.newStore(); - this.stack = []; - this.history = []; - this.lastSay = null; - this.textbox.classList.add("pg-hidden"); - this.cur = { label: this.game.entry, path: [0] }; - this.markSeen(this.game.entry); - this.settle(); - }); - } - - private snapshotState(): SaveData { - return { - v: 1, - cur: { label: this.cur.label, path: [...this.cur.path] }, - stack: this.stack.map((f) => ({ label: f.label, path: [...f.path] })), - store: encodeValue(this.store), - scene: this.renderer.snapshot(), - music: this.audio.current("music"), - window: this.windowShown, - text: this.lastSay ? `${this.lastSay.name ? this.lastSay.name + ": " : ""}${plainText(parseText(this.lastSay.text))}` : "", - }; - } - - private async save(slot: string, silent = false) { - if (!this.running || !this.cur.label) return; - const data = this.snapshotState(); - const title = (data.text ?? "").slice(0, 120) || this.cur.label; - try { - await this.saves.put(slot, title, data); - if (!silent) this.notify("Сохранено"); - } catch (err) { - if (!silent) this.notify(`Не удалось сохранить: ${(err as Error).message}`); - } - } - - private async load(slot: string) { - const data = await this.saves.get(slot); - if (!data) { - this.notify("Сохранение не найдено"); - return; - } - if (!this.labels.has(data.cur.label)) { - this.notify("Сохранение не подходит к этой версии игры"); - return; - } - this.restart(() => { - this.resetStore(); - Object.assign(this.store, decodeValue(data.store)); - this.renderer.scene(); - for (const s of data.scene) { - const img = this.resolveImage(s.name); - if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder }); - } - this.renderer.commit(); - this.audio.stopAll(); - if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 }); - this.windowShown = data.window; - this.stack = data.stack; - this.cur = data.cur; - this.lastSay = null; - this.history = []; - }); - } - - private setSkip(on: boolean) { - this.skip = on; - this.renderer.fast = on; - this.quick?.querySelector("[data-act=skip]")?.classList.toggle("pg-on", on); - this.skipTick?.(); - } - - private setAuto(on: boolean) { - this.auto = on; - this.quick.querySelector("[data-act=auto]")?.classList.toggle("pg-on", on); - this.skipTick?.(); - } - - // --- UI ------------------------------------------------------------------------- - - private buildUI() { - const { ui } = this.game; - const [w, h] = this.game.resolution; - const stage = this.renderer.stage; - const px = (v: any, total: number, fallback: number) => - typeof v === "number" ? (Number.isInteger(v) && v > 1 ? v : v * total) : fallback; - stage.style.setProperty("--pg-font", `${this.baseSize}px`); - stage.style.setProperty("--pg-name-font", `${Number(ui.name_text_size) || Math.round(this.baseSize * 1.2)}px`); - if (ui.accent_color) stage.style.setProperty("--pg-accent", ui.accent_color); - if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color); - this.loadFont("pg-game-font", ui.text_font); - this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font); - - this.textbox = el("div", "pg-textbox pg-hidden"); - const boxH = px(ui.textbox_height, h, Math.round(h * 0.257)); - this.textbox.style.height = `${boxH}px`; - if (ui.textbox) { - const url = this.fileUrl(ui.textbox); - if (url) this.textbox.style.backgroundImage = `url("${url}")`; - this.textbox.classList.add("pg-textbox-image"); - } - this.namebox = el("div", "pg-namebox"); - this.namebox.style.left = `${px(ui.name_xpos, w, Math.round(w * 0.1875))}px`; - this.namebox.style.transform = "translateX(-50%)"; - if (!ui.name_xpos) this.namebox.style.transform = ""; - this.textEl = el("div", "pg-text"); - this.textEl.style.left = `${px(ui.dialogue_xpos, w, Math.round(w * 0.21))}px`; - this.textEl.style.top = `${px(ui.dialogue_ypos, boxH, Math.round(boxH * 0.18))}px`; - this.textEl.style.width = `${px(ui.dialogue_width, w, Math.round(w * 0.58))}px`; - this.ctc = el("span", "pg-ctc", "▼"); - this.textbox.append(this.namebox, this.textEl); - - this.quick = el("div", "pg-quick"); - const actions: [string, string, () => void][] = [ - ["history", "История", () => this.openHistory()], - ["skip", "Пропуск", () => this.setSkip(!this.skip)], - ["auto", "Авто", () => this.setAuto(!this.auto)], - ["save", "Сохр.", () => this.openSaves("save")], - ["load", "Загр.", () => this.openSaves("load")], - ["qsave", "Б.Сохр.", () => void this.save("quick")], - ["qload", "Б.Загр.", () => void this.load("quick")], - ["prefs", "Настройки", () => this.openSettings()], - ["menu", "Меню", () => this.openGameMenu()], - ]; - for (const [act, label, fn] of actions) { - const b = el("button", "", label); - b.dataset.act = act; - b.onclick = (ev) => { - ev.stopPropagation(); - fn(); - }; - this.quick.append(b); - } - this.textbox.append(this.quick); - - this.menuEl = el("div", "pg-menu pg-hidden"); - this.nvlEl = el("div", "pg-nvl pg-hidden"); - if (ui.nvl) { - const url = this.fileUrl(ui.nvl); - if (url) this.nvlEl.style.backgroundImage = `url("${url}")`; - } - this.centeredEl = el("div", "pg-centered pg-hidden"); - this.screen = el("div", "pg-screen pg-hidden"); - this.notifyEl = el("div", "pg-notify pg-hidden"); - stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screen, this.notifyEl); - - stage.addEventListener("click", (ev) => { - if (!this.screen.classList.contains("pg-hidden")) return; - if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick")) return; - this.advanceInput(); - }); - stage.addEventListener("wheel", (ev) => { - if (ev.deltaY < 0 && this.screen.classList.contains("pg-hidden") && this.running) this.openHistory(); - }, { passive: true }); - } - - private loadFont(family: string, file?: string) { - const url = file ? this.fileUrl(file) : null; - if (!url) return; - const face = new FontFace(family, `url("${url}")`); - face.load().then((f) => { - document.fonts.add(f); - this.renderer.stage.classList.add(family); - }).catch(() => {}); - } - - private advanceInput() { - if (this.skip) { - this.setSkip(false); - return; - } - if (!this.textbox.classList.contains("pg-ui-hidden")) this.onAdvance?.(); - this.textbox.classList.remove("pg-ui-hidden"); - } - - private onKey = (ev: KeyboardEvent) => { - if ((ev.target as HTMLElement)?.tagName === "INPUT") return; - const screenOpen = !this.screen.classList.contains("pg-hidden"); - if (ev.key === "Escape") { - if (this.screen.classList.contains("pg-main")) return; - if (screenOpen) this.dismissScreen(); - else if (this.running) this.openGameMenu(); - return; - } - if (screenOpen) return; - if (ev.key === " " || ev.key === "Enter") { - ev.preventDefault(); - this.advanceInput(); - } else if (ev.key === "Control" && !this.skip) { - this.setSkip(true); - } else if (ev.key === "Tab") { - ev.preventDefault(); - this.setSkip(!this.skip); - } else if (ev.key === "a" || ev.key === "ф") { - this.setAuto(!this.auto); - } else if (ev.key === "h" || ev.key === "р") { - this.textbox.classList.toggle("pg-ui-hidden"); - } - }; - - private onKeyUp = (ev: KeyboardEvent) => { - if (ev.key === "Control") this.setSkip(false); - }; - - private screenOnClose: (() => void) | null = null; - - private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void) { - this.screen.textContent = ""; - this.screen.classList.remove("pg-main"); - this.screen.style.backgroundImage = ""; - this.screenOnClose = onClose ?? null; - const head = el("div", "pg-screen-head"); - head.append(el("h2", "", title)); - const close = el("button", "pg-close", "✕"); - close.onclick = () => this.dismissScreen(); - head.append(close); - const body = el("div", "pg-screen-body"); - this.screen.append(head, body); - build(body); - this.screen.classList.remove("pg-hidden"); - } - - private dismissScreen() { - const fn = this.screenOnClose; - if (fn) fn(); - else this.closeScreen(); - } - - private closeScreen() { - this.screenOnClose = null; - this.screen.classList.add("pg-hidden"); - this.screen.classList.remove("pg-main"); - this.screen.style.backgroundImage = ""; - } - - private showMainMenu() { - this.running = false; - this.gen++; - this.wakeAll(); - this.audio.stopAll(); - this.textbox.classList.add("pg-hidden"); - this.menuEl.classList.add("pg-hidden"); - this.clearNvl(); - this.screen.textContent = ""; - this.screen.classList.add("pg-main"); - const bg = this.game.ui.mainMenu ? this.fileUrl(this.game.ui.mainMenu) : null; - if (bg) this.screen.style.backgroundImage = `url("${bg}")`; - const panel = el("div", "pg-main-panel"); - panel.append(el("h1", "", this.game.title ?? "Новелла")); - const btn = (label: string, fn: () => void) => { - const b = el("button", "", label); - b.onclick = fn; - panel.append(b); - return b; - }; - btn("Начать", () => this.newGame()); - const cont = btn("Продолжить", () => void this.load("auto")); - cont.style.display = "none"; - this.saves.list().then((list) => { - if (list.some((s) => s.slot === "auto")) cont.style.display = ""; - }).catch(() => {}); - btn("Загрузить", () => this.openSaves("load", true)); - btn("Настройки", () => this.openSettings(true)); - btn("В библиотеку", () => this.onExit()); - this.screen.append(panel); - this.screen.classList.remove("pg-hidden"); - } - - private openGameMenu() { - this.openScreen("Меню", (body) => { - const list = el("div", "pg-menu-list"); - const add = (label: string, fn: () => void) => { - const b = el("button", "", label); - b.onclick = fn; - list.append(b); - }; - add("Вернуться", () => this.closeScreen()); - add("Сохранить", () => this.openSaves("save")); - add("Загрузить", () => this.openSaves("load")); - add("Настройки", () => this.openSettings()); - add("Главное меню", () => { - if (confirm("Выйти в главное меню? Несохранённый прогресс будет потерян.")) this.showMainMenu(); - }); - add("В библиотеку", () => this.onExit()); - body.append(list); - }); - } - - private openHistory() { - this.openScreen("История", (body) => { - const list = el("div", "pg-history"); - for (const h of this.history) { - const row = el("div", "pg-history-row"); - if (h.name) row.append(el("b", "", h.name)); - row.append(el("span", "", h.text)); - list.append(row); - } - body.append(list); - requestAnimationFrame(() => (list.scrollTop = list.scrollHeight)); - }); - } - - private openSaves(mode: "save" | "load", fromMain = false) { - this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => { - const grid = el("div", "pg-slots"); - body.append(grid); - const render = (list: SaveMeta[]) => { - grid.textContent = ""; - const bySlot = new Map(list.map((s) => [s.slot, s])); - const slots = [...(mode === "load" ? ["auto", "quick"] : []), - ...Array.from({ length: SLOTS }, (_, i) => String(i + 1))]; - for (const slot of slots) { - const meta = bySlot.get(slot); - const card = el("button", "pg-slot"); - const label = slot === "auto" ? "Авто" : slot === "quick" ? "Быстрое" : `Слот ${slot}`; - card.append(el("b", "", label)); - card.append(el("span", "pg-slot-title", meta ? meta.title : "Пусто")); - if (meta) card.append(el("small", "", new Date(meta.updated_at).toLocaleString())); - card.disabled = mode === "load" && !meta; - card.onclick = async () => { - if (mode === "save") { - if (meta && !confirm("Перезаписать сохранение?")) return; - await this.save(slot); - render(await this.saves.list()); - } else { - await this.load(slot); - } - }; - grid.append(card); - } - }; - this.saves.list().then(render).catch((e) => this.notify(String(e))); - }, fromMain ? () => this.showMainMenu() : undefined); - } - - private openSettings(fromMain = false) { - this.openScreen("Настройки", (body) => { - const s = this.settings; - const row = (label: string, min: number, max: number, step: number, value: number, - fmt: (v: number) => string, set: (v: number) => void) => { - const r = el("label", "pg-setting"); - const out = el("span", "pg-setting-val", fmt(value)); - const input = el("input"); - Object.assign(input, { type: "range", min, max, step, value: String(value) }); - input.oninput = () => { - set(Number(input.value)); - out.textContent = fmt(Number(input.value)); - saveJSON("pgvn:settings", this.settings); - this.audio.setVolumes(this.settings); - }; - r.append(el("span", "", label), input, out); - body.append(r); - }; - row("Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "мгновенно" : `${v} симв/с`), (v) => (s.cps = v)); - row("Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v)); - row("Музыка", 0, 1, 0.05, s.music, (v) => `${Math.round(v * 100)}%`, (v) => (s.music = v)); - row("Звуки", 0, 1, 0.05, s.sound, (v) => `${Math.round(v * 100)}%`, (v) => (s.sound = v)); - row("Голос", 0, 1, 0.05, s.voice, (v) => `${Math.round(v * 100)}%`, (v) => (s.voice = v)); - const fs = el("button", "pg-wide", "Полный экран"); - fs.onclick = () => { - if (document.fullscreenElement) void document.exitFullscreen(); - else void this.root.requestFullscreen(); - }; - body.append(fs); - }, fromMain ? () => this.showMainMenu() : undefined); - } -} - -function childBlock(node: Node, branch: number): Node[] { - if (node.op === "if") return node.branches[branch].body; - if (node.op === "menu") return node.items[branch].body; - return node.body; -} - -export type { ImageDef }; +// PGVN interpreter + in-game UI. + +import { AudioSystem, type Volumes } from "./audio"; +import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, type Scope } from "./python"; +import { Renderer, type ResolvedImage } from "./renderer"; +import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; +import type { + CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, +} from "./types"; + +export interface SaveMeta { + slot: string; + title: string; + updated_at: string; +} + +export interface SaveApi { + list(): Promise; + get(slot: string): Promise; + put(slot: string, title: string, data: SaveData): Promise; + remove(slot: string): Promise; +} + +interface Settings extends Volumes { + cps: number; // characters per second, 0 = instant + autoDelay: number; // seconds per 20 characters in auto mode +} + +const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 }; +const SLOTS = 12; + +interface LabelRef { + label: Label; + module: ScriptModule; + index: number; +} + +class Restart extends Error {} + +function el(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text) e.textContent = text; + return e; +} + +function loadJSON(key: string, fallback: T): T { + try { + const raw = localStorage.getItem(key); + return raw ? { ...fallback, ...JSON.parse(raw) } : fallback; + } catch { + return fallback; + } +} + +function saveJSON(key: string, value: unknown) { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* storage unavailable */ + } +} + +export class Engine { + private labels = new Map(); + private anchors = new Map(); + private imagesByTag = new Map(); + private store: Record = {}; + private builtins: Record = {}; + private persistent: Record; + private py: Python; + private renderer: Renderer; + private audio: AudioSystem; + private settings: Settings; + + private cur: Frame = { label: "", path: [0] }; + private stack: Frame[] = []; + private gen = 0; + private waiters: (() => void)[] = []; + private running = false; + + private skip = false; + private auto = false; + private windowShown = true; + private lastSay: { name: string; text: string; color?: string; char?: CharacterDef } | null = null; + private history: { name: string; text: string }[] = []; + private nvlLines: HTMLElement[] = []; + private interactions = 0; + + // UI + private container: HTMLDivElement; + private textbox!: HTMLDivElement; + private namebox!: HTMLDivElement; + private textEl!: HTMLDivElement; + private ctc!: HTMLSpanElement; + private menuEl!: HTMLDivElement; + private nvlEl!: HTMLDivElement; + private centeredEl!: HTMLDivElement; + private quick!: HTMLDivElement; + private screen!: HTMLDivElement; + private notifyEl!: HTMLDivElement; + private baseSize: number; + private resizeObserver: ResizeObserver; + + constructor( + private root: HTMLElement, + private game: GameData, + private manifest: Manifest, + modules: ScriptModule[], + private saves: SaveApi, + private persistentKey: string, + private onExit: () => void, + ) { + for (const mod of modules) { + let global = ""; + mod.labels.forEach((label, index) => { + if (label.name.startsWith(".")) label.name = global + label.name; + else global = label.name.split(".")[0]; + this.labels.set(label.name, { label, module: mod, index }); + }); + for (const [name, anchor] of Object.entries(mod.anchors ?? {})) this.anchors.set(name, anchor); + } + for (const key of Object.keys(game.images)) { + const [tag, ...attrs] = key.split(" "); + if (!this.imagesByTag.has(tag)) this.imagesByTag.set(tag, []); + this.imagesByTag.get(tag)!.push({ attrs, key }); + } + + this.settings = loadJSON("pgvn:settings", DEFAULT_SETTINGS); + this.persistent = this.makePersistent(); + const scope: Scope = { + get: (n) => (n in this.store ? this.store[n] : this.builtins[n]), + has: (n) => n in this.store || n in this.builtins, + set: (n, v) => void (this.store[n] = v), + del: (n) => void delete this.store[n], + }; + this.py = new Python(scope); + this.builtins = this.makeBuiltins(); + + const [w, h] = game.resolution; + this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306); + this.container = el("div", "pg-root"); + root.append(this.container); + this.renderer = new Renderer(this.container, w, h, { + url: (f) => this.fileUrl(f), + resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null, + evalCond: (c) => truthy(this.safeEval(c)), + transform: (name) => game.transforms[name] ?? null, + text: (e) => this.interp(pyStr(this.safeEval(e))), + }); + this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {}); + this.buildUI(); + this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container)); + this.resizeObserver.observe(this.container); + this.renderer.fit(this.container); + window.addEventListener("keydown", this.onKey); + window.addEventListener("keyup", this.onKeyUp); + } + + destroy() { + this.gen++; + this.wakeAll(); + this.audio.stopAll(); + this.resizeObserver.disconnect(); + window.removeEventListener("keydown", this.onKey); + window.removeEventListener("keyup", this.onKeyUp); + this.container.remove(); + } + + // --- files & images ---------------------------------------------------------- + + fileUrl(path: string): string | null { + const clean = path.replace(/^<[^>]*>/, "").replace(/\\/g, "/").replace(/^\/+/, ""); + let logical: string | undefined = this.manifest.files[clean] ? clean : undefined; + if (!logical) { + const low = clean.toLowerCase(); + for (const prefix of ["", "images/", "audio/", "music/", "sound/", "sounds/"]) { + logical = this.game.files[prefix + low]; + if (logical) break; + } + } + const entry = logical ? this.manifest.files[logical] : undefined; + return entry ? `/blobs/${entry.hash}` : null; + } + + private resolveImage(name: string[]): ResolvedImage | null { + const full = name.join(" "); + const [tag, ...attrs] = name; + const layered = this.game.images[tag]; + if (layered?.layered) return this.resolveLayered(tag, layered, attrs); + if (this.game.images[full]) return { def: this.game.images[full], name }; + const candidates = this.imagesByTag.get(tag) ?? []; + let best: { attrs: string[]; key: string } | null = null; + for (const c of candidates) { + if (attrs.every((a) => c.attrs.includes(a)) && (!best || c.attrs.length < best.attrs.length)) best = c; + } + if (!best && attrs.length) { + // keep attributes of the currently shown image that the new ones don't replace + const current = this.renderer.attrsOf(tag)?.slice(1) ?? []; + for (const c of candidates) { + if (attrs.every((a) => c.attrs.includes(a)) || !c.attrs.some((a) => attrs.includes(a))) continue; + const keeps = current.filter((a) => c.attrs.includes(a)).length; + if (!best || keeps > 0) best = c; + } + } + if (!best) return null; + return { def: this.game.images[best.key], name: best.key.split(" ") }; + } + + /** Attributes of a layeredimage: new ones replace current ones of the same group. */ + private resolveLayered(tag: string, def: ImageDef, attrs: string[]): ResolvedImage { + const layers = def.layered!; + const groupOf = (a: string) => layers.find((l) => l.attr === a)?.group ?? null; + const removed = attrs.filter((a) => a.startsWith("-")).map((a) => a.slice(1)); + const added = attrs.filter((a) => !a.startsWith("-")); + const current = (this.renderer.attrsOf(tag)?.slice(1) ?? []).filter((a) => { + if (removed.includes(a)) return false; + const g = groupOf(a); + return !added.some((n) => n === a || (g !== null && groupOf(n) === g)); + }); + const selected = [...current, ...added]; + // `group x auto`: attributes backed by images named __ + const extra: Layer[] = []; + for (const l of layers) { + if (!l.autoGroup) continue; + for (const a of selected) { + const ref = `${tag}_${l.autoGroup}_${a}`; + if (this.game.images[ref] && !layers.some((x) => x.attr === a)) + extra.push({ attr: a, group: l.autoGroup, img: { ref } }); + } + } + return { def: { ...def, layered: [...layers, ...extra], selected }, name: [tag, ...selected] }; + } + + private imageFromSpec(spec: any): ResolvedImage | null { + if (spec.name) return this.resolveImage(spec.name); + if (spec.text !== undefined) return { def: { text: spec.text }, name: ["text"] }; + if (spec.textExpr) return { def: { textExpr: spec.textExpr }, name: ["text"] }; + if (spec.expr) { + const v = this.safeEval(spec.expr); + if (typeof v !== "string") return null; + if (v.startsWith("#")) return { def: { color: v }, name: [v] }; + if (v.includes(".")) return { def: { file: v }, name: [v.split("/").pop()!.split(".")[0]] }; + return this.resolveImage(v.split(" ")); + } + return null; + } + + // --- python environment ---------------------------------------------------------- + + private safeEval(e: Expr | null | undefined): any { + try { + return this.py.eval(e); + } catch (err) { + if (err instanceof JumpSignal) throw err; + console.warn("[pgvn] ошибка выражения", err); + return null; + } + } + + private makePersistent(): Record { + const data = decodeValue(loadJSON>(this.persistentKey, {})); + const save = () => saveJSON(this.persistentKey, encodeValue(data)); + return new Proxy(data, { + get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined : null), + set: (t, k: string, v) => { + t[k] = v; + save(); + return true; + }, + }); + } + + private makeBuiltins(): Record { + const kwless = (args: any[]) => + args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args; + const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {}; + const random = { + randint: (a: number, b: number) => a + Math.floor(Math.random() * (b - a + 1)), + random: () => Math.random(), + choice: (xs: any[]) => xs[Math.floor(Math.random() * xs.length)], + shuffle: (xs: any[]) => { + for (let i = xs.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [xs[i], xs[j]] = [xs[j], xs[i]]; + } + }, + uniform: (a: number, b: number) => a + Math.random() * (b - a), + }; + const music = (ch: string) => ({ + play: (...a: any[]) => { + const kw = kwOf(a); + const files = kwless(a)[0]; + this.audio.play(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String), + { loop: kw.loop, fadein: kw.fadein, fadeout: kw.fadeout }); + }, + queue: (...a: any[]) => { + const kw = kwOf(a); + const files = kwless(a)[0]; + this.audio.queue(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String), kw.loop); + }, + stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0), + set_volume: () => {}, + get_playing: () => this.audio.current(ch)?.file ?? null, + is_playing: () => this.audio.current(ch) !== null, + }); + const renpyImpl: Record = { + random, + music: music("music"), + sound: music("sound"), + play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}), + notify: (msg: any) => this.notify(pyStr(msg)), + jump: (label: string) => { + throw new JumpSignal("jump", label); + }, + call: (label: string) => { + throw new JumpSignal("call", label); + }, + input: (...a: any[]) => { + const [prompt, def] = kwless(a); + const kw = kwOf(a); + const v = window.prompt(pyStr(prompt ?? ""), pyStr(def ?? kw.default ?? "")); + return (v ?? pyStr(def ?? kw.default ?? "")).slice(0, kw.length ?? 1000); + }, + has_label: (l: string) => this.labels.has(l), + seen_label: (l: string) => truthy(this.persistent._seen?.includes?.(l)), + showing: (tag: string) => this.renderer.isShown(String(tag).split(" ")[0]), + get_attributes: (tag: string) => this.renderer.attrsOf(tag)?.slice(1) ?? [], + full_restart: () => { + throw new JumpSignal("jump", "__main_menu__"); + }, + quit: () => { + throw new JumpSignal("jump", "__quit__"); + }, + version: () => "pgvn", + }; + const renpy = new Proxy(renpyImpl, { + get: (t, k: string) => { + if (k in t) return t[k]; + this.py.warnOnce(`renpy.${k} не поддерживается — пропущено`); + return () => null; + }, + }); + const num = (x: any) => (typeof x === "string" ? Number(x.trim()) : Number(x)); + return { + renpy, + persistent: this.persistent, + config: {}, + preferences: {}, + _preferences: {}, + gui: {}, + store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null, + set: (_t, k: string, v) => ((this.store[k] = v), true) }), + audio: this.game.audio, + random, + math: { floor: Math.floor, ceil: Math.ceil, sqrt: Math.sqrt, pi: Math.PI, pow: Math.pow }, + len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0), + str: (x: any) => pyStr(x), + int: (x: any) => Math.trunc(num(x)), + float: (x: any) => num(x), + bool: (x: any) => truthy(x), + abs: Math.abs, + round: (x: number, n = 0) => Math.round(x * 10 ** n) / 10 ** n, + min: (...a: any[]) => (a.length === 1 ? Math.min(...a[0]) : Math.min(...a)), + max: (...a: any[]) => (a.length === 1 ? Math.max(...a[0]) : Math.max(...a)), + sum: (xs: number[]) => xs.reduce((s, x) => s + x, 0), + any: (xs: any[]) => xs.some(truthy), + all: (xs: any[]) => xs.every(truthy), + range: (a: number, b?: number, step = 1) => { + const [lo, hi] = b === undefined ? [0, a] : [a, b]; + const out: number[] = []; + for (let i = lo; step > 0 ? i < hi : i > hi; i += step) out.push(i); + return out; + }, + list: (xs: any = []) => [...(xs instanceof PyDict ? xs.map.keys() : xs)], + tuple: (xs: any = []) => [...xs], + set: (xs: any = []) => [...new Set(xs)], + dict: (...a: any[]) => { + const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []); + for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v); + return d; + }, + sorted: (xs: any[]) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), + reversed: (xs: any[]) => [...xs].reverse(), + enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]), + zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])), + isinstance: () => false, + _: (s: string) => s, + __: (s: string) => s, + }; + } + + private resetStore() { + this.store = {}; + try { + this.py.exec(this.game.init); + } catch (err) { + console.warn("[pgvn] ошибка init", err); + } + } + + private newStore() { + this.resetStore(); + for (const [name, e] of this.game.defaults) { + if (!(name in this.store)) this.store[name] = this.safeEval(e); + } + } + + // --- control flow ------------------------------------------------------------------ + + private blockFor(frame: Frame): Node[] { + let block = this.labels.get(frame.label)!.label.body; + for (let k = 0; k < frame.path.length - 1; k += 2) { + block = childBlock(block[frame.path[k]], frame.path[k + 1]); + } + return block; + } + + private node(): Node | null { + if (!this.labels.has(this.cur.label)) return null; + return this.blockFor(this.cur)[this.cur.path[this.cur.path.length - 1]] ?? null; + } + + /** After a path change, move past ended blocks (falling through labels / looping whiles). */ + private settle() { + for (let guard = 0; guard < 10000; guard++) { + const p = this.cur.path; + if (!this.labels.has(this.cur.label)) return; + const block = this.blockFor(this.cur); + if (p[p.length - 1] < block.length) return; + if (p.length === 1) { + const ref = this.labels.get(this.cur.label)!; + const next = ref.module.labels[ref.index + 1]; + if (next) { + this.cur = { label: next.name, path: [0] }; + continue; + } + this.doReturn(); + return; + } + const parent = p.slice(0, -2); + const parentNode = this.blockFor({ label: this.cur.label, path: parent })[parent[parent.length - 1]]; + this.cur.path = parent; + if (parentNode.op !== "while") this.cur.path[parent.length - 1]++; + } + } + + private advance() { + this.cur.path[this.cur.path.length - 1]++; + this.settle(); + } + + private enter(branch: number) { + this.cur.path.push(branch, 0); + this.settle(); + } + + private jump(target: string, args?: { a: any[]; kw: Record }, frame?: Frame) { + if (target === "__main_menu__" || target === "__quit__") { + throw new Restart(target); + } + let name = target; + if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name; + const anchor = this.labels.has(name) ? null : this.anchors.get(name); + if (!this.labels.has(name) && !anchor) { + this.notify(`Метка «${target}» не найдена`); + console.warn("[pgvn] нет метки", target); + if (frame) this.stack.pop(); + this.advance(); + return; + } + const params = anchor ? anchor.params : this.labels.get(name)!.label.params; + if (params?.length) this.bindParams(params, args, frame); + this.cur = anchor ? { label: anchor.label, path: [...anchor.path] } : { label: name, path: [0] }; + this.markSeen(name); + this.settle(); + } + + /** Label parameters are dynamically scoped: a call restores old values on return. */ + private bindParams(params: [string, Expr | null][], args?: { a: any[]; kw: Record }, + frame?: Frame) { + const values: Record = {}; + params.forEach(([p, def], i) => { + if (args && i < args.a.length) values[p] = args.a[i]; + else if (args && p in args.kw) values[p] = args.kw[p]; + else values[p] = def ? this.safeEval(def) : null; + }); + if (frame) { + frame.saved = {}; + for (const p of Object.keys(values)) frame.saved[p] = { had: p in this.store, value: this.store[p] }; + } + Object.assign(this.store, values); + } + + private call(target: string, argExprs?: { a: Expr[]; kw: Record }) { + // arguments are evaluated in the caller's scope, before parameters are bound + const args = argExprs && { + a: argExprs.a.map((e) => this.safeEval(e)), + kw: Object.fromEntries(Object.entries(argExprs.kw).map(([k, e]) => [k, this.safeEval(e)])), + }; + const frame: Frame = { label: this.cur.label, path: [...this.cur.path] }; + this.stack.push(frame); + this.jump(target, args, frame); + } + + private doReturn() { + const frame = this.stack.pop(); + if (!frame) { + this.cur = { label: "", path: [0] }; + return; + } + for (const [p, old] of Object.entries(frame.saved ?? {})) { + if (old.had) this.store[p] = old.value; + else delete this.store[p]; + } + this.cur = { label: frame.label, path: frame.path }; + this.advance(); + } + + private markSeen(label: string) { + const seen: string[] = this.persistent._seen ?? []; + if (!seen.includes(label)) this.persistent._seen = [...seen, label]; + } + + // --- main loop -------------------------------------------------------------------- + + private wait(setup: (done: (v: T) => void) => void): Promise { + const gen = this.gen; + return new Promise((resolve, reject) => { + const wake = () => reject(new Restart()); + this.waiters.push(wake); + setup((v) => { + this.waiters = this.waiters.filter((w) => w !== wake); + if (gen === this.gen) resolve(v); + else reject(new Restart()); + }); + }); + } + + private wakeAll() { + const ws = this.waiters; + this.waiters = []; + ws.forEach((w) => w()); + } + + private async loop() { + const gen = this.gen; + this.running = true; + try { + while (gen === this.gen) { + if (!this.cur.label) { + this.showMainMenu(); + return; + } + const node = this.node(); + if (!node) { + this.settle(); + if (!this.node()) { + this.cur = { label: "", path: [0] }; + } + continue; + } + try { + await this.exec(node); + } catch (err) { + if (err instanceof JumpSignal) { + if (err.kind === "jump") this.jump(err.target); + else this.call(err.target); + } else throw err; + } + } + } catch (err) { + if (err instanceof Restart) { + if (err.message === "__main_menu__") this.showMainMenu(); + if (err.message === "__quit__") this.onExit(); + return; + } + console.error(err); + this.notify(`Ошибка плеера: ${(err as Error).message}`); + } finally { + if (gen === this.gen) this.running = false; + } + } + + private async exec(node: Node) { + switch (node.op) { + case "say": + await this.say(node); + this.advance(); + return; + case "scene": + case "show": + case "hide": { + if (node.with) this.renderer.commit(); + if (node.op === "scene") { + this.renderer.scene(); + this.clearNvl(); + } + if (node.op === "hide") this.renderer.hide(node.tag); + else if (node.img) this.showNode(node); + if (node.with) await this.renderer.transition(this.transition(node.with)); + this.advance(); + return; + } + case "with": + await this.renderer.transition(this.transition(node.t)); + this.advance(); + return; + case "play": + case "queue": { + const files = node.files.map((f: Expr) => this.audioFile(f)).filter(Boolean) as string[]; + if (files.length) { + if (node.op === "play") + this.audio.play(node.ch, files, { loop: node.loop, fadein: node.fadein, fadeout: node.fadeout, + ifChanged: node.ifChanged }); + else this.audio.queue(node.ch, files, node.loop); + } + this.advance(); + return; + } + case "stop": + this.audio.stop(node.ch, node.fadeout ?? 0); + this.advance(); + return; + case "voice": + if (node.file) { + const f = this.audioFile(node.file); + if (f) this.audio.play("voice", [f], { loop: false }); + } + this.advance(); + return; + case "pause": { + const d = node.d ? Number(this.safeEval(node.d)) : null; + await this.pause(d); + this.advance(); + return; + } + case "menu": + return this.menu(node); + case "if": { + const i = node.branches.findIndex((b: any) => b.c === null || truthy(this.safeEval(b.c))); + if (i >= 0) this.enter(i); + else this.advance(); + return; + } + case "while": + if (truthy(this.safeEval(node.c))) this.enter(0); + else this.advance(); + return; + case "jump": + return this.jump(node.target ?? pyStr(this.safeEval(node.expr))); + case "call": + return this.call(node.target ?? pyStr(this.safeEval(node.expr)), node.args); + case "camera": + this.renderer.camera(node.at ?? []); + this.advance(); + return; + case "return": + this.doReturn(); + return; + case "exec": + try { + this.py.exec(node.code); + } catch (err) { + if (err instanceof JumpSignal) throw err; + console.warn("[pgvn] ошибка python", err, node.code); + } + this.advance(); + return; + case "window": + this.windowShown = node.mode !== "hide"; + if (!this.windowShown) this.textbox.classList.add("pg-hidden"); + this.advance(); + return; + case "nvl": + if (node.a === "clear") this.clearNvl(); + else this.nvlEl.classList.toggle("pg-hidden", node.a === "hide"); + this.advance(); + return; + default: + this.advance(); + } + } + + private showNode(node: Node) { + const img = this.imageFromSpec(node.img); + if (!img) { + console.warn("[pgvn] нет изображения", node.img); + return; + } + const tag = node.as ?? (node.img.name ? node.img.name[0] : img.name[0]); + const zorder = node.zorder ? Number(this.safeEval(node.zorder)) : undefined; + this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind }); + } + + private transition(t: any) { + if (t && t.t === "ref") return this.game.transitions[t.name] ?? { t: "dissolve", d: 0.5 }; + return t; + } + + private audioFile(e: Expr): string | null { + if (e.k === "name" && this.game.audio[e.id] && !(e.id in this.store)) return this.game.audio[e.id]; + const v = this.safeEval(e); + if (typeof v !== "string") return null; + return this.game.audio[v] ?? v; + } + + // --- interactions ----------------------------------------------------------------- + + private interactionStart() { + this.renderer.commit(); + this.interactions++; + if (this.interactions % 15 === 0) void this.save("auto", true); + this.preloadAhead(); + } + + private preloadAhead() { + const block = this.labels.has(this.cur.label) ? this.blockFor(this.cur) : []; + const start = this.cur.path[this.cur.path.length - 1]; + for (const n of block.slice(start + 1, start + 12)) { + if ((n.op === "show" || n.op === "scene") && n.img?.name) this.renderer.preload(this.resolveImage(n.img.name)?.def ?? null); + } + } + + private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } { + const who: string | undefined = node.who; + if (node.whoStr !== undefined) return { char: {}, name: node.whoStr, mode: "adv" }; + if (!who || who === "narrator") return { char: {}, name: "", mode: "adv" }; + if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" }; + if (who === "extend") return { char: this.lastSay?.char ?? {}, name: this.lastSay?.name ?? "", mode: "extend" }; + if (who === "nvl") return { char: { nvl: true }, name: "", mode: "nvl" }; + const char = this.game.characters[who]; + if (!char) { + const v = this.store[who]; + return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" }; + } + let name = char.name ?? ""; + if (char.nameExpr) name = pyStr(this.safeEval(char.nameExpr)); + name = this.interp(name); + return { char, name: `${char.whoPrefix ?? ""}${name}${char.whoSuffix ?? ""}`, mode: char.nvl ? "nvl" : "adv" }; + } + + private interp(s: string): string { + return interpolate(s, (expr) => this.lookupPath(expr)); + } + + private lookupPath(expr: string): any { + const m = expr.match(/^[A-Za-z_][\w]*/); + if (!m) return ""; + let v = this.py.lookup(m[0]); + const rest = expr.slice(m[0].length); + for (const part of rest.matchAll(/\.(\w+)|\[(\d+|'[^']*'|"[^"]*")\]/g)) { + if (v === null || v === undefined) return ""; + const k = part[1] ?? JSON.parse(part[2].replace(/^'|'$/g, '"')); + v = v instanceof PyDict ? v.get(k) : v[k]; + } + return v ?? ""; + } + + private async say(node: Node, inMenu = false) { + const { char, name, mode } = this.characterFor(node); + if (node.attrs && char.image && this.renderer.isShown(char.image)) { + const img = this.resolveImage([char.image, ...node.attrs]); + if (img) this.renderer.show(img, char.image, []); + } + if (!inMenu) this.interactionStart(); + if (node.with) await this.renderer.transition(this.transition(node.with)); + + let raw = this.interp(node.what); + raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`; + let prefixLen = 0; + if (mode === "extend" && this.lastSay) { + prefixLen = [...plainText(parseText(this.lastSay.text))].length; + raw = this.lastSay.text + raw; + } + const parsed = parseText(raw); + this.lastSay = { name, text: raw, color: char.color, char }; + if (mode !== "extend") this.history.push({ name, text: plainText(parsed) }); + else if (this.history.length) this.history[this.history.length - 1].text = plainText(parsed); + if (this.history.length > 300) this.history.shift(); + + let target: HTMLElement; + if (mode === "centered") { + this.textbox.classList.add("pg-hidden"); + this.centeredEl.classList.remove("pg-hidden"); + target = this.centeredEl; + } else if (mode === "nvl") { + this.textbox.classList.add("pg-hidden"); + this.nvlEl.classList.remove("pg-hidden"); + const line = el("div", "pg-nvl-line"); + if (name) { + const who = el("span", "pg-nvl-name", name); + if (char.color) who.style.color = char.color; + line.append(who); + } + const what = el("span", "pg-nvl-what"); + line.append(what); + this.nvlEl.append(line); + this.nvlLines.push(line); + target = what; + } else { + this.centeredEl.classList.add("pg-hidden"); + this.nvlEl.classList.add("pg-hidden"); + this.textbox.classList.remove("pg-hidden"); + this.namebox.textContent = name; + this.namebox.classList.toggle("pg-hidden", !name); + this.namebox.style.color = char.color ?? ""; + this.textEl.style.color = char.whatColor ?? ""; + this.textEl.style.fontStyle = char.whatItalic ? "italic" : ""; + this.textEl.style.fontWeight = char.whatBold ? "bold" : ""; + target = this.textEl; + } + + await this.typewrite(target, parsed, Math.max(prefixLen, fastPoint(parsed)), inMenu); + if (inMenu || node.interact === false) return; + if (!parsed.noWait) await this.waitAdvance(parsed.length); + this.audio.stop("voice"); + this.centeredEl.classList.add("pg-hidden"); + } + + private async typewrite(target: HTMLElement, parsed: ReturnType, from: number, instant = false) { + this.ctc.remove(); + const total = parsed.length; + const waits = waitPoints(parsed).filter((w) => w.at > from); + let shown = from; + const cps = this.settings.cps; + if (instant || cps <= 0 || this.skip) { + renderText(target, parsed, total, this.baseSize); + target.append(this.ctc); + return; + } + let completed = false; + const finishClick = () => { + completed = true; + }; + this.onAdvance = finishClick; + try { + for (const stop of [...waits.map((w) => w.at), total]) { + let last = performance.now(); + while (shown < stop && !completed && !this.skip) { + await this.frame(); + const now = performance.now(); + shown = Math.min(stop, shown + ((now - last) / 1000) * cps); + last = now; + renderText(target, parsed, Math.floor(shown), this.baseSize); + } + shown = completed || this.skip ? total : stop; + renderText(target, parsed, Math.floor(shown), this.baseSize); + if (completed || this.skip) break; + const w = waits.find((x) => x.at === stop); + if (w) { + if (w.seconds !== null) await this.pause(w.seconds); + else await this.waitAdvance(0); + this.onAdvance = finishClick; + } + } + } finally { + this.onAdvance = null; + } + target.append(this.ctc); + } + + private frame(): Promise { + return this.wait((done) => requestAnimationFrame(() => done())); + } + + private onAdvance: (() => void) | null = null; + + /** Wait for the player to advance (click/space), or skip/auto. */ + private waitAdvance(chars: number): Promise { + return this.wait((done) => { + let timer = 0; + const finish = () => { + clearTimeout(timer); + this.onAdvance = null; + this.skipTick = null; + done(); + }; + this.onAdvance = finish; + if (this.skip) timer = window.setTimeout(finish, 40); + else if (this.auto) timer = window.setTimeout(finish, (1 + chars / 20) * this.settings.autoDelay * 1000); + this.skipTick = () => { + clearTimeout(timer); + if (this.skip) timer = window.setTimeout(finish, 40); + else if (this.auto) timer = window.setTimeout(finish, 1000 * this.settings.autoDelay); + }; + }); + } + + private skipTick: (() => void) | null = null; + + private pause(seconds: number | null): Promise { + if (seconds === null) { + this.interactionStart(); + return this.waitAdvance(0); + } + if (this.skip) return Promise.resolve(); + return this.wait((done) => { + const timer = window.setTimeout(() => { + this.onAdvance = null; + done(); + }, seconds * 1000); + this.onAdvance = () => { + clearTimeout(timer); + this.onAdvance = null; + done(); + }; + }); + } + + private async menu(node: Node) { + this.interactionStart(); + const chosen: string[] = node.set ? this.safeEval(node.set) ?? [] : []; + if (node.say) await this.say(node.say, true); + else if (!this.windowShown) this.textbox.classList.add("pg-hidden"); + const items = node.items + .map((item: any, i: number) => ({ ...item, i })) + .filter((item: any) => (item.if === null || truthy(this.safeEval(item.if))) && !chosen.includes(item.text)); + if (!items.length) { + this.advance(); + return; + } + this.setSkip(false); + const index = await this.wait((done) => { + this.menuEl.textContent = ""; + for (const item of items) { + const b = el("button", "pg-choice"); + renderText(b, parseText(this.interp(item.text)), Infinity, this.baseSize); + b.onclick = (ev) => { + ev.stopPropagation(); + done(item.i); + }; + this.menuEl.append(b); + } + this.menuEl.classList.remove("pg-hidden"); + }); + this.menuEl.classList.add("pg-hidden"); + if (Array.isArray(chosen) && node.set) chosen.push(node.items[index].text); + this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(node.items[index].text)))}` }); + this.enter(index); + } + + private clearNvl() { + this.nvlLines.forEach((l) => l.remove()); + this.nvlLines = []; + this.nvlEl.classList.add("pg-hidden"); + } + + private notify(msg: string) { + this.notifyEl.textContent = msg; + this.notifyEl.classList.remove("pg-hidden"); + window.setTimeout(() => this.notifyEl.classList.add("pg-hidden"), 3000); + } + + // --- session control -------------------------------------------------------------- + + private restart(setup: () => void) { + this.gen++; + this.wakeAll(); + this.onAdvance = null; + this.skipTick = null; + this.closeScreen(); + this.menuEl.classList.add("pg-hidden"); + this.centeredEl.classList.add("pg-hidden"); + this.clearNvl(); + this.setSkip(false); + setup(); + void this.loop(); + } + + start() { + this.showMainMenu(); + } + + private newGame() { + this.restart(() => { + this.audio.stopAll(); + this.renderer.scene(); + this.renderer.camera([]); + this.renderer.commit(); + this.newStore(); + this.stack = []; + this.history = []; + this.lastSay = null; + this.textbox.classList.add("pg-hidden"); + this.cur = { label: this.game.entry, path: [0] }; + this.markSeen(this.game.entry); + this.settle(); + }); + } + + private snapshotState(): SaveData { + return { + v: 1, + cur: { label: this.cur.label, path: [...this.cur.path] }, + stack: this.stack.map((f) => ({ label: f.label, path: [...f.path], saved: encodeValue(f.saved) })), + store: encodeValue(this.store), + scene: this.renderer.snapshot(), + music: this.audio.current("music"), + window: this.windowShown, + camera: this.renderer.cameraAt, + text: this.lastSay ? `${this.lastSay.name ? this.lastSay.name + ": " : ""}${plainText(parseText(this.lastSay.text))}` : "", + }; + } + + private async save(slot: string, silent = false) { + if (!this.running || !this.cur.label) return; + const data = this.snapshotState(); + const title = (data.text ?? "").slice(0, 120) || this.cur.label; + try { + await this.saves.put(slot, title, data); + if (!silent) this.notify("Сохранено"); + } catch (err) { + if (!silent) this.notify(`Не удалось сохранить: ${(err as Error).message}`); + } + } + + private async load(slot: string) { + const data = await this.saves.get(slot); + if (!data) { + this.notify("Сохранение не найдено"); + return; + } + if (!this.labels.has(data.cur.label)) { + this.notify("Сохранение не подходит к этой версии игры"); + return; + } + this.restart(() => { + this.resetStore(); + Object.assign(this.store, decodeValue(data.store)); + this.renderer.scene(); + for (const s of data.scene) { + const img = this.resolveImage(s.name); + if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder }); + } + this.renderer.commit(); + this.audio.stopAll(); + if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 }); + this.windowShown = data.window; + this.renderer.camera(data.camera ?? []); + this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) })); + this.cur = data.cur; + this.lastSay = null; + this.history = []; + }); + } + + private setSkip(on: boolean) { + this.skip = on; + this.renderer.fast = on; + this.quick?.querySelector("[data-act=skip]")?.classList.toggle("pg-on", on); + this.skipTick?.(); + } + + private setAuto(on: boolean) { + this.auto = on; + this.quick.querySelector("[data-act=auto]")?.classList.toggle("pg-on", on); + this.skipTick?.(); + } + + // --- UI ------------------------------------------------------------------------- + + private buildUI() { + const { ui } = this.game; + const [w, h] = this.game.resolution; + const stage = this.renderer.stage; + const px = (v: any, total: number, fallback: number) => + typeof v === "number" ? (Number.isInteger(v) && v > 1 ? v : v * total) : fallback; + stage.style.setProperty("--pg-font", `${this.baseSize}px`); + stage.style.setProperty("--pg-name-font", `${Number(ui.name_text_size) || Math.round(this.baseSize * 1.2)}px`); + if (ui.accent_color) stage.style.setProperty("--pg-accent", ui.accent_color); + if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color); + this.loadFont("pg-game-font", ui.text_font); + this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font); + + this.textbox = el("div", "pg-textbox pg-hidden"); + const boxH = px(ui.textbox_height, h, Math.round(h * 0.257)); + this.textbox.style.height = `${boxH}px`; + if (ui.textbox) { + const url = this.fileUrl(ui.textbox); + if (url) this.textbox.style.backgroundImage = `url("${url}")`; + this.textbox.classList.add("pg-textbox-image"); + } + this.namebox = el("div", "pg-namebox"); + this.namebox.style.left = `${px(ui.name_xpos, w, Math.round(w * 0.1875))}px`; + this.namebox.style.transform = "translateX(-50%)"; + if (!ui.name_xpos) this.namebox.style.transform = ""; + this.textEl = el("div", "pg-text"); + this.textEl.style.left = `${px(ui.dialogue_xpos, w, Math.round(w * 0.21))}px`; + this.textEl.style.top = `${px(ui.dialogue_ypos, boxH, Math.round(boxH * 0.18))}px`; + this.textEl.style.width = `${px(ui.dialogue_width, w, Math.round(w * 0.58))}px`; + this.ctc = el("span", "pg-ctc", "▼"); + this.textbox.append(this.namebox, this.textEl); + + this.quick = el("div", "pg-quick"); + const actions: [string, string, () => void][] = [ + ["history", "История", () => this.openHistory()], + ["skip", "Пропуск", () => this.setSkip(!this.skip)], + ["auto", "Авто", () => this.setAuto(!this.auto)], + ["save", "Сохр.", () => this.openSaves("save")], + ["load", "Загр.", () => this.openSaves("load")], + ["qsave", "Б.Сохр.", () => void this.save("quick")], + ["qload", "Б.Загр.", () => void this.load("quick")], + ["prefs", "Настройки", () => this.openSettings()], + ["menu", "Меню", () => this.openGameMenu()], + ]; + for (const [act, label, fn] of actions) { + const b = el("button", "", label); + b.dataset.act = act; + b.onclick = (ev) => { + ev.stopPropagation(); + fn(); + }; + this.quick.append(b); + } + this.textbox.append(this.quick); + + this.menuEl = el("div", "pg-menu pg-hidden"); + this.nvlEl = el("div", "pg-nvl pg-hidden"); + if (ui.nvl) { + const url = this.fileUrl(ui.nvl); + if (url) this.nvlEl.style.backgroundImage = `url("${url}")`; + } + this.centeredEl = el("div", "pg-centered pg-hidden"); + this.screen = el("div", "pg-screen pg-hidden"); + this.notifyEl = el("div", "pg-notify pg-hidden"); + stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screen, this.notifyEl); + + stage.addEventListener("click", (ev) => { + if (!this.screen.classList.contains("pg-hidden")) return; + if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick")) return; + this.advanceInput(); + }); + stage.addEventListener("wheel", (ev) => { + if (ev.deltaY < 0 && this.screen.classList.contains("pg-hidden") && this.running) this.openHistory(); + }, { passive: true }); + } + + private loadFont(family: string, file?: string) { + const url = file ? this.fileUrl(file) : null; + if (!url) return; + const face = new FontFace(family, `url("${url}")`); + face.load().then((f) => { + document.fonts.add(f); + this.renderer.stage.classList.add(family); + }).catch(() => {}); + } + + private advanceInput() { + if (this.skip) { + this.setSkip(false); + return; + } + if (!this.textbox.classList.contains("pg-ui-hidden")) this.onAdvance?.(); + this.textbox.classList.remove("pg-ui-hidden"); + } + + private onKey = (ev: KeyboardEvent) => { + if ((ev.target as HTMLElement)?.tagName === "INPUT") return; + const screenOpen = !this.screen.classList.contains("pg-hidden"); + if (ev.key === "Escape") { + if (this.screen.classList.contains("pg-main")) return; + if (screenOpen) this.dismissScreen(); + else if (this.running) this.openGameMenu(); + return; + } + if (screenOpen) return; + if (ev.key === " " || ev.key === "Enter") { + ev.preventDefault(); + this.advanceInput(); + } else if (ev.key === "Control" && !this.skip) { + this.setSkip(true); + } else if (ev.key === "Tab") { + ev.preventDefault(); + this.setSkip(!this.skip); + } else if (ev.key === "a" || ev.key === "ф") { + this.setAuto(!this.auto); + } else if (ev.key === "h" || ev.key === "р") { + this.textbox.classList.toggle("pg-ui-hidden"); + } + }; + + private onKeyUp = (ev: KeyboardEvent) => { + if (ev.key === "Control") this.setSkip(false); + }; + + private screenOnClose: (() => void) | null = null; + + private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void) { + this.screen.textContent = ""; + this.screen.classList.remove("pg-main"); + this.screen.style.backgroundImage = ""; + this.screenOnClose = onClose ?? null; + const head = el("div", "pg-screen-head"); + head.append(el("h2", "", title)); + const close = el("button", "pg-close", "✕"); + close.onclick = () => this.dismissScreen(); + head.append(close); + const body = el("div", "pg-screen-body"); + this.screen.append(head, body); + build(body); + this.screen.classList.remove("pg-hidden"); + } + + private dismissScreen() { + const fn = this.screenOnClose; + if (fn) fn(); + else this.closeScreen(); + } + + private closeScreen() { + this.screenOnClose = null; + this.screen.classList.add("pg-hidden"); + this.screen.classList.remove("pg-main"); + this.screen.style.backgroundImage = ""; + } + + private showMainMenu() { + this.running = false; + this.gen++; + this.wakeAll(); + this.audio.stopAll(); + this.textbox.classList.add("pg-hidden"); + this.menuEl.classList.add("pg-hidden"); + this.clearNvl(); + this.screen.textContent = ""; + this.screen.classList.add("pg-main"); + const bg = this.game.ui.mainMenu ? this.fileUrl(this.game.ui.mainMenu) : null; + if (bg) this.screen.style.backgroundImage = `url("${bg}")`; + const panel = el("div", "pg-main-panel"); + panel.append(el("h1", "", this.game.title ?? "Новелла")); + const btn = (label: string, fn: () => void) => { + const b = el("button", "", label); + b.onclick = fn; + panel.append(b); + return b; + }; + btn("Начать", () => this.newGame()); + const cont = btn("Продолжить", () => void this.load("auto")); + cont.style.display = "none"; + this.saves.list().then((list) => { + if (list.some((s) => s.slot === "auto")) cont.style.display = ""; + }).catch(() => {}); + btn("Загрузить", () => this.openSaves("load", true)); + btn("Настройки", () => this.openSettings(true)); + btn("В библиотеку", () => this.onExit()); + this.screen.append(panel); + this.screen.classList.remove("pg-hidden"); + } + + private openGameMenu() { + this.openScreen("Меню", (body) => { + const list = el("div", "pg-menu-list"); + const add = (label: string, fn: () => void) => { + const b = el("button", "", label); + b.onclick = fn; + list.append(b); + }; + add("Вернуться", () => this.closeScreen()); + add("Сохранить", () => this.openSaves("save")); + add("Загрузить", () => this.openSaves("load")); + add("Настройки", () => this.openSettings()); + add("Главное меню", () => { + if (confirm("Выйти в главное меню? Несохранённый прогресс будет потерян.")) this.showMainMenu(); + }); + add("В библиотеку", () => this.onExit()); + body.append(list); + }); + } + + private openHistory() { + this.openScreen("История", (body) => { + const list = el("div", "pg-history"); + for (const h of this.history) { + const row = el("div", "pg-history-row"); + if (h.name) row.append(el("b", "", h.name)); + row.append(el("span", "", h.text)); + list.append(row); + } + body.append(list); + requestAnimationFrame(() => (list.scrollTop = list.scrollHeight)); + }); + } + + private openSaves(mode: "save" | "load", fromMain = false) { + this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => { + const grid = el("div", "pg-slots"); + body.append(grid); + const render = (list: SaveMeta[]) => { + grid.textContent = ""; + const bySlot = new Map(list.map((s) => [s.slot, s])); + const slots = [...(mode === "load" ? ["auto", "quick"] : []), + ...Array.from({ length: SLOTS }, (_, i) => String(i + 1))]; + for (const slot of slots) { + const meta = bySlot.get(slot); + const card = el("button", "pg-slot"); + const label = slot === "auto" ? "Авто" : slot === "quick" ? "Быстрое" : `Слот ${slot}`; + card.append(el("b", "", label)); + card.append(el("span", "pg-slot-title", meta ? meta.title : "Пусто")); + if (meta) card.append(el("small", "", new Date(meta.updated_at).toLocaleString())); + card.disabled = mode === "load" && !meta; + card.onclick = async () => { + if (mode === "save") { + if (meta && !confirm("Перезаписать сохранение?")) return; + await this.save(slot); + render(await this.saves.list()); + } else { + await this.load(slot); + } + }; + grid.append(card); + } + }; + this.saves.list().then(render).catch((e) => this.notify(String(e))); + }, fromMain ? () => this.showMainMenu() : undefined); + } + + private openSettings(fromMain = false) { + this.openScreen("Настройки", (body) => { + const s = this.settings; + const row = (label: string, min: number, max: number, step: number, value: number, + fmt: (v: number) => string, set: (v: number) => void) => { + const r = el("label", "pg-setting"); + const out = el("span", "pg-setting-val", fmt(value)); + const input = el("input"); + Object.assign(input, { type: "range", min, max, step, value: String(value) }); + input.oninput = () => { + set(Number(input.value)); + out.textContent = fmt(Number(input.value)); + saveJSON("pgvn:settings", this.settings); + this.audio.setVolumes(this.settings); + }; + r.append(el("span", "", label), input, out); + body.append(r); + }; + row("Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "мгновенно" : `${v} симв/с`), (v) => (s.cps = v)); + row("Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v)); + row("Музыка", 0, 1, 0.05, s.music, (v) => `${Math.round(v * 100)}%`, (v) => (s.music = v)); + row("Звуки", 0, 1, 0.05, s.sound, (v) => `${Math.round(v * 100)}%`, (v) => (s.sound = v)); + row("Голос", 0, 1, 0.05, s.voice, (v) => `${Math.round(v * 100)}%`, (v) => (s.voice = v)); + const fs = el("button", "pg-wide", "Полный экран"); + fs.onclick = () => { + if (document.fullscreenElement) void document.exitFullscreen(); + else void this.root.requestFullscreen(); + }; + body.append(fs); + }, fromMain ? () => this.showMainMenu() : undefined); + } +} + +function childBlock(node: Node, branch: number): Node[] { + if (node.op === "if") return node.branches[branch].body; + if (node.op === "menu") return node.items[branch].body; + return node.body; +} + +export type { ImageDef }; diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index ea2a78d..7182830 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -1,287 +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> = { - 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 | null; -} - -interface Entry extends ShownImage { - el: HTMLElement; -} - -export class Renderer { - readonly stage: HTMLDivElement; - readonly master: HTMLDivElement; - private overlay: HTMLDivElement; - private shown = new Map(); - 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) { - const d = document.createElement("div"); - d.className = "pg-show-text"; - d.textContent = def.text; - return d; - } - 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"); - } - - private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) { - const p: Record = { ...POSITIONS.default, ...(def.props ?? {}) }; - for (const spec of at) { - 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 { - 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) { - 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((r) => setTimeout(r, seconds * 1000)); -} - -function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise { - 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> = { + 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 | 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(); + 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 = {}; + 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 = { ...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 { + 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) { + 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((r) => setTimeout(r, seconds * 1000)); +} + +function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise { + return new Promise((resolve) => { + const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" }); + a.onfinish = () => resolve(); + a.oncancel = () => resolve(); + }); +} diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index 099a731..e895d84 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -1,108 +1,143 @@ -// 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; - props?: Record; -} - -export interface AtSpec { - pos?: string; - name?: string; - props?: Record; -} - -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[]; -} - -export interface ScriptModule { - file: string; - labels: Label[]; -} - -export interface GameData { - format: string; - title: string | null; - resolution: [number, number]; - entry: string; - scripts: string[]; - characters: Record; - images: Record; - transforms: Record>; - transitions: Record; - audio: Record; - files: Record; - defaults: [string, Expr][]; - init: PyStmt[]; - ui: Record; -} - -export interface Manifest { - format: string; - files: Record; -} - -export interface Frame { - label: string; - path: number[]; -} - -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; - 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; + 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; + 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; +} + +export interface GameData { + format: string; + title: string | null; + resolution: [number, number]; + entry: string; + scripts: string[]; + characters: Record; + images: Record; + transforms: Record>; + transitions: Record; + audio: Record; + channels?: Record; + files: Record; + defaults: [string, Expr][]; + init: PyStmt[]; + ui: Record; +} + +export interface Manifest { + format: string; + files: Record; +} + +export interface Frame { + label: string; + path: number[]; + // values of label parameters shadowed by a call, restored on return + saved?: Record; +} + +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; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 9691386..d75b662 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,153 +1,153 @@ -:root { - --bg: #0f1117; - --panel: #171a23; - --panel-2: #1f2330; - --border: #2a2f3e; - --text: #e6e8ef; - --muted: #8b90a0; - --accent: #ff9f43; - --danger: #ef5a5a; - --ok: #4cc38a; - color-scheme: dark; -} - -* { box-sizing: border-box; } -html, body, #root { height: 100%; margin: 0; } -body { - background: var(--bg); - color: var(--text); - font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif; -} -a { color: inherit; text-decoration: none; } -h1 { font-size: 1.6rem; margin: 0 0 0.75rem; } -h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; } - -input, select, textarea, button, .button { - font: inherit; - color: var(--text); - background: var(--panel-2); - border: 1px solid var(--border); - border-radius: 6px; - padding: 0.45rem 0.7rem; -} -input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); } -button, .button { cursor: pointer; white-space: nowrap; } -button:hover:not(:disabled), .button:hover { border-color: var(--accent); } -button:disabled { opacity: 0.5; cursor: default; } -button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; } -button.danger { color: var(--danger); } -button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); } -button.link:hover { color: var(--text); } - -.muted { color: var(--muted); } -.small { font-size: 0.85em; } -.error { color: var(--danger); margin: 0.5rem 0; } -.ok { color: var(--ok); } -.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; } - -.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; } -.auth-card { width: min(380px, 100%); display: flex; flex-direction: column; gap: 0.8rem; } -.card label, .auth-card label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); } -.card form, form.card { display: flex; flex-direction: column; gap: 0.8rem; } -.inline-form { flex-direction: row !important; flex-wrap: wrap; align-items: center; margin: 1rem 0; } -.inline-form input { flex: 1; min-width: 200px; } - -.layout { min-height: 100%; display: flex; flex-direction: column; } -.topbar { - display: flex; - align-items: center; - gap: 1.5rem; - padding: 0.7rem 1.5rem; - background: var(--panel); - border-bottom: 1px solid var(--border); - flex-wrap: wrap; -} -.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; } -.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; } -.topbar nav a { color: var(--muted); } -.topbar nav a.active, .topbar nav a:hover { color: var(--text); } -.user { display: flex; align-items: center; gap: 0.3rem; } - -.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; } -.page.narrow { max-width: 560px; } - -.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; } -.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } -.game-card { - background: var(--panel); - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; - transition: transform 0.15s, border-color 0.15s; -} -.game-card:hover { transform: translateY(-3px); border-color: var(--accent); } -.game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; } -.cover { - aspect-ratio: 16 / 10; - background: linear-gradient(135deg, #262b3b, #1a1d29); - display: flex; - align-items: center; - justify-content: center; - font-size: 2.5rem; - color: var(--muted); - overflow: hidden; -} -.cover img { width: 100%; height: 100%; object-fit: cover; } -.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; } -.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; } -.tag { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); color: var(--muted); } - -.game-head { display: flex; gap: 1.5rem; margin-top: 1rem; flex-wrap: wrap; } -.game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; } -.description { white-space: pre-wrap; margin: 0; } -.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } -.editor { display: flex; flex-direction: column; gap: 0.6rem; } - -.versions { display: flex; flex-direction: column; gap: 0.6rem; } -.version { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; } -.version-main { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; } -.status { margin-left: 0.6rem; font-size: 0.8rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); } -.status-ready { color: var(--ok); } -.status-failed { color: var(--danger); } -.status-queued, .status-converting, .status-uploading { color: var(--accent); } -.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; } -.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; } -.report table { border-collapse: collapse; margin-top: 0.4rem; } -.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; } -.report summary { cursor: pointer; margin: 0.3rem 0; } - -.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; } -.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; } - -.table-wrap { overflow-x: auto; } -.table { width: 100%; border-collapse: collapse; margin-top: 1rem; } -.table th, .table td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); } -.table th { color: var(--muted); font-weight: 500; } -.table tr.inactive td { opacity: 0.55; } -.center-cell { text-align: center !important; } -.roles th:not(:first-child) { text-align: center; } - -.issued { border-color: var(--accent); margin-bottom: 1rem; } -.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } -.password-box code { font-size: 1.2rem; padding: 0.3rem 0.8rem; background: var(--panel-2); border-radius: 6px; user-select: all; } - -.play { position: fixed; inset: 0; background: #000; } -.play-host { position: absolute; inset: 0; } -.play-status { - position: absolute; - inset: 0; - display: flex; - flex-direction: column; - gap: 1rem; - align-items: center; - justify-content: center; - color: var(--muted); - pointer-events: none; -} -.play-status button { pointer-events: auto; } - -@media (max-width: 640px) { - .page { padding: 1rem; } - .topbar { padding: 0.6rem 1rem; gap: 0.8rem; } - .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } -} +:root { + --bg: #0f1117; + --panel: #171a23; + --panel-2: #1f2330; + --border: #2a2f3e; + --text: #e6e8ef; + --muted: #8b90a0; + --accent: #ff9f43; + --danger: #ef5a5a; + --ok: #4cc38a; + color-scheme: dark; +} + +* { box-sizing: border-box; } +html, body, #root { height: 100%; margin: 0; } +body { + background: var(--bg); + color: var(--text); + font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif; +} +a { color: inherit; text-decoration: none; } +h1 { font-size: 1.6rem; margin: 0 0 0.75rem; } +h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; } + +input, select, textarea, button, .button { + font: inherit; + color: var(--text); + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.45rem 0.7rem; +} +input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); } +button, .button { cursor: pointer; white-space: nowrap; } +button:hover:not(:disabled), .button:hover { border-color: var(--accent); } +button:disabled { opacity: 0.5; cursor: default; } +button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; } +button.danger { color: var(--danger); } +button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); } +button.link:hover { color: var(--text); } + +.muted { color: var(--muted); } +.small { font-size: 0.85em; } +.error { color: var(--danger); margin: 0.5rem 0; } +.ok { color: var(--ok); } +.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; } + +.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; } +.auth-card { width: min(380px, 100%); display: flex; flex-direction: column; gap: 0.8rem; } +.card label, .auth-card label { display: flex; flex-direction: column; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); } +.card form, form.card { display: flex; flex-direction: column; gap: 0.8rem; } +.inline-form { flex-direction: row !important; flex-wrap: wrap; align-items: center; margin: 1rem 0; } +.inline-form input { flex: 1; min-width: 200px; } + +.layout { min-height: 100%; display: flex; flex-direction: column; } +.topbar { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 0.7rem 1.5rem; + background: var(--panel); + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; } +.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; } +.topbar nav a { color: var(--muted); } +.topbar nav a.active, .topbar nav a:hover { color: var(--text); } +.user { display: flex; align-items: center; gap: 0.3rem; } + +.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; } +.page.narrow { max-width: 560px; } + +.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.game-card { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 10px; + overflow: hidden; + transition: transform 0.15s, border-color 0.15s; +} +.game-card:hover { transform: translateY(-3px); border-color: var(--accent); } +.game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; } +.cover { + aspect-ratio: 16 / 10; + background: linear-gradient(135deg, #262b3b, #1a1d29); + display: flex; + align-items: center; + justify-content: center; + font-size: 2.5rem; + color: var(--muted); + overflow: hidden; +} +.cover img { width: 100%; height: 100%; object-fit: cover; } +.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; } +.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; } +.tag { font-size: 0.75rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); color: var(--muted); } + +.game-head { display: flex; gap: 1.5rem; margin-top: 1rem; flex-wrap: wrap; } +.game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; } +.description { white-space: pre-wrap; margin: 0; } +.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } +.editor { display: flex; flex-direction: column; gap: 0.6rem; } + +.versions { display: flex; flex-direction: column; gap: 0.6rem; } +.version { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 0.75rem 1rem; } +.version-main { display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; align-items: center; } +.status { margin-left: 0.6rem; font-size: 0.8rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); } +.status-ready { color: var(--ok); } +.status-failed { color: var(--danger); } +.status-queued, .status-converting, .status-importing, .status-uploading { color: var(--accent); } +.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; } +.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; } +.report table { border-collapse: collapse; margin-top: 0.4rem; } +.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; } +.report summary { cursor: pointer; margin: 0.3rem 0; } + +.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; } +.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; } + +.table-wrap { overflow-x: auto; } +.table { width: 100%; border-collapse: collapse; margin-top: 1rem; } +.table th, .table td { text-align: left; padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--border); } +.table th { color: var(--muted); font-weight: 500; } +.table tr.inactive td { opacity: 0.55; } +.center-cell { text-align: center !important; } +.roles th:not(:first-child) { text-align: center; } + +.issued { border-color: var(--accent); margin-bottom: 1rem; } +.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } +.password-box code { font-size: 1.2rem; padding: 0.3rem 0.8rem; background: var(--panel-2); border-radius: 6px; user-select: all; } + +.play { position: fixed; inset: 0; background: #000; } +.play-host { position: absolute; inset: 0; } +.play-status { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + justify-content: center; + color: var(--muted); + pointer-events: none; +} +.play-status button { pointer-events: auto; } + +@media (max-width: 640px) { + .page { padding: 1rem; } + .topbar { padding: 0.6rem 1rem; gap: 0.8rem; } + .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } +}