Converter: cover real Ren'Py games better, add folder import and dry run

Checked against six real games (Ren'Py 7.5 and 8.x):
- nested labels and named menus become jump anchors inside label bodies
- call with arguments and labels with parameters (dynamically scoped)
- frame-by-frame ATL images, Movie frames, layeredimage, camera, Shake,
  custom audio channels via register_channel, show text with expressions
- generator expressions in Python, no SyntaxWarning noise from decompiled code

Also:
- app.import_dir converts an unpacked game folder without changing it
- app.converter.analyze dry-runs the script part of the converter
- imports use their own "importing" status so a worker restart can't hijack them

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 03:23:20 +03:00
1 parent 35033fb86c
commit a97b46079e
18 files changed
+3456 -2695

No files matched your search

+102 -78
View File
@@ -1,78 +1,102 @@
# PGames — локальная библиотека визуальных новелл # PGames — локальная библиотека визуальных новелл
Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит Загружаете архив с игрой на Ren'Py — сервер распаковывает его, переводит
скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM) скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM)
и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры и удаляет исходники. Играете в браузере во встроенном плеере. У каждой игры
может быть несколько версий (форков) от разных пользователей; версии делят может быть несколько версий (форков) от разных пользователей; версии делят
общие ассеты, поэтому форк почти не занимает места. общие ассеты, поэтому форк почти не занимает места.
Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
## Установка на VM ## Установка на VM
```bash ```bash
git clone <repo> pgames && cd pgames git clone <repo> pgames && cd pgames
cp .env.example .env cp .env.example .env
# обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли # обязательно поменяйте SECRET_KEY (openssl rand -hex 32) и пароли
docker compose up -d --build docker compose up -d --build
``` ```
Приложение слушает `http://<IP VM>:8080` (порт — `HTTP_PORT` в `.env`). Приложение слушает `http://<IP VM>:8080` (порт — `HTTP_PORT` в `.env`).
Caddy на хосте Proxmox: Caddy на хосте Proxmox:
``` ```
games.example.lan { games.example.lan {
reverse_proxy 192.168.1.50:8080 reverse_proxy 192.168.1.50:8080
} }
``` ```
Если в браузере сайт открывается по HTTPS через Caddy, оставьте Если в браузере сайт открывается по HTTPS через Caddy, оставьте
`COOKIE_SECURE=true`; при доступе напрямую по HTTP поставьте `false`, иначе `COOKIE_SECURE=true`; при доступе напрямую по HTTP поставьте `false`, иначе
браузер не сохранит сессию. браузер не сохранит сессию.
## Первый вход ## Первый вход
Логин `admin` / пароль из `ADMIN_PASSWORD` (по умолчанию `admin`). Сразу после Логин `admin` / пароль из `ADMIN_PASSWORD` (по умолчанию `admin`). Сразу после
входа нужно задать свой логин и пароль. входа нужно задать свой логин и пароль.
Регистрации нет: пользователей создаёт админ в разделе «Пользователи». Регистрации нет: пользователей создаёт админ в разделе «Пользователи».
Сервер генерирует пароль и показывает его один раз — передайте его человеку, Сервер генерирует пароль и показывает его один раз — передайте его человеку,
при первом входе он задаст свой. Права настраиваются через роли при первом входе он задаст свой. Права настраиваются через роли
(«Роли»): `admin`, `moderator`, `player` плюс свои. («Роли»): `admin`, `moderator`, `player` плюс свои.
## Данные и бэкап ## Импорт уже распакованных игр
* `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256); Если игры уже лежат на VM распакованными, архивировать их не нужно.
* том `db` — PostgreSQL (пользователи, игры, версии, сохранения). Подключите папку к контейнеру (только чтение) и запустите импорт:
Для бэкапа достаточно `pg_dump` и копии `data/blobs`. ```bash
docker compose run --rm -v /srv/games:/games:ro backend \
## Разработка python -m app.import_dir "/games/Some-Game" --title "Some Game"
```
На 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
```bash docker compose run --rm -v /srv/games:/games:ro backend \
cd backend && pip install -r requirements.txt pytest && python -m pytest python -W ignore -m app.converter.analyze "/games/Some-Game"
cd frontend && npm install && npm run dev # проксирует /api на :8000 ```
python -m tests.make_sample sample.zip # тестовая игра (из backend/)
``` ## Данные и бэкап
## Что поддерживает конвертер Ren'Py * `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256);
* том `db` — PostgreSQL (пользователи, игры, версии, сохранения).
`label`, реплики (в т.ч. атрибуты, `extend`, `centered`, NVL), `scene/show/hide`
с `at`/`as`/`behind`/`zorder`/`with`, переходы (`dissolve`, `fade`, Для бэкапа достаточно `pg_dump` и копии `data/blobs`.
`Fade(...)`, `vpunch`, wipe/slide…), `menu` с условиями и `set`, `if/elif/else`,
`while`, `jump/call/return` (включая `expression`), `$`/`python:`/`init python:` ## Разработка
(подмножество Python: переменные, выражения, списки/словари, циклы),
`define/default`, `Character(...)`, `image` (файлы, цвета, `Transform`, `im.*`, На Windows тестовый стенд поднимается одной командой: `start-test.bat`.
`Composite`, `ConditionSwitch`, простые ATL), `transform` со статическими Он запускает отдельный compose-проект `pgames-test` на http://localhost:8090
свойствами, `play/queue/stop/voice`, `pause`, `.rpa`, `.rpyc` (через unrpyc), (свой `.env.test`, данные в `data-test\`), генерирует тестовую игру
автоопределение картинок из `images/`, GUI-текстбокс и шрифты игры. `data-test\sample.zip` и открывает браузер. `start-test.bat stop` — остановить,
`reset` — удалить стенд вместе с данными, `logs` — логи бэкенда и воркера.
Пока **не** поддерживается: `screen`-язык (кастомные экраны и мини-игры),
анимации ATL, `layeredimage`, функции/классы в `python` — такие места ```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` и картинки,
которые игра вычисляет своими функциями. Такие места перечислены в отчёте
конвертации у версии. Игры-«песочницы», где навигация построена на экранах,
пока не играбельны.
+144
View File
@@ -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)
+57 -5
View File
@@ -36,6 +36,7 @@ class Result:
def convert_archive(archive: Path, filename: str, work: Path, progress: Progress) -> 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() started = time.monotonic()
if work.exists(): if work.exists():
shutil.rmtree(work) shutil.rmtree(work)
@@ -44,9 +45,14 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
out_dir.mkdir(parents=True) out_dir.mkdir(parents=True)
data = ScriptData() data = ScriptData()
progress("Распаковка архива") if archive.is_dir():
extract(archive, filename, src_root) progress("Копирование файлов игры")
game_dir = find_game_dir(src_root) 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")) rpas = sorted(game_dir.rglob("*.rpa"))
for i, rpa in enumerate(rpas, 1): 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] = [] scripts: list[str] = []
for rel, labels in data.modules.items(): for rel, labels in data.modules.items():
logical = "script/" + str(PurePosixPath(rel).with_suffix(".json")) logical = "script/" + str(PurePosixPath(rel).with_suffix(".json"))
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() separators=(",", ":")).encode()
blob_id, size = put_bytes(payload, ".json") blob_id, size = put_bytes(payload, ".json")
files[logical] = {"hash": blob_id, "size": size} 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, "transforms": data.transforms,
"transitions": data.transitions, "transitions": data.transitions,
"audio": audio, "audio": audio,
"channels": data.channels,
"files": aliases, "files": aliases,
"defaults": data.defaults, "defaults": data.defaults,
"init": data.init_code, "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)} {"message": msg, "count": n, "example": data.warning_examples.get(msg)}
for msg, n in data.warnings.most_common() for msg, n in data.warnings.most_common()
], ],
"sourceSize": archive.stat().st_size, "sourceSize": _size(archive),
"outputSize": total, "outputSize": total,
} }
return Result({"format": FORMAT, "files": files}, report, title, cover, 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: def _is_engine_file(game_dir: Path, p: Path) -> bool:
parts = [x.lower() for x in p.relative_to(game_dir).parts] parts = [x.lower() for x in p.relative_to(game_dir).parts]
return len(parts) > 1 and parts[0] in ("cache", "saves", "tl", "renpy") 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: if "composite" in img:
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)} img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
for layer in img["composite"]] 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 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: def _auto_images(data: ScriptData, aliases: dict[str, str]) -> None:
"""Ren'Py defines an image for every file under images/ (name = lowercase basename).""" """Ren'Py defines an image for every file under images/ (name = lowercase basename)."""
for rel, logical in aliases.items(): for rel, logical in aliases.items():
+189 -185
View File
@@ -1,185 +1,189 @@
"""Translate the subset of Python used by visual novels into PGVN IR. """Translate the subset of Python used by visual novels into PGVN IR.
Expressions become JSON trees ``{"k": kind, ...}``; statements become Expressions become JSON trees ``{"k": kind, ...}``; statements become
``{"op": ..., ...}`` nodes executed by the player's JS interpreter. ``{"op": ..., ...}`` nodes executed by the player's JS interpreter.
Anything outside the subset raises :class:`Unsupported` so the caller can Anything outside the subset raises :class:`Unsupported` so the caller can
report it and degrade gracefully. report it and degrade gracefully.
""" """
import ast import ast
from typing import Any from typing import Any
Expr = dict[str, Any] Expr = dict[str, Any]
Stmt = dict[str, Any] Stmt = dict[str, Any]
BIN_OPS = { BIN_OPS = {
ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//", ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.FloorDiv: "//",
ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^", ast.Mod: "%", ast.Pow: "**", ast.BitAnd: "&", ast.BitOr: "|", ast.BitXor: "^",
ast.LShift: "<<", ast.RShift: ">>", ast.LShift: "<<", ast.RShift: ">>",
} }
CMP_OPS = { CMP_OPS = {
ast.Eq: "==", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.Gt: ">", ast.GtE: ">=", 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", 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: "~"} UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports. # Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"} KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"}
class Unsupported(Exception): class Unsupported(Exception):
pass pass
def const(v: Any) -> Expr: def const(v: Any) -> Expr:
return {"k": "const", "v": v} return {"k": "const", "v": v}
def parse_expr(source: str) -> Expr: def parse_expr(source: str) -> Expr:
try: try:
tree = ast.parse(source.strip(), mode="eval") tree = ast.parse(source.strip(), mode="eval")
except SyntaxError as e: except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
return expr(tree.body) return expr(tree.body)
def parse_stmts(source: str) -> list[Stmt]: def parse_stmts(source: str) -> list[Stmt]:
try: try:
tree = ast.parse(_dedent(source)) tree = ast.parse(_dedent(source))
except SyntaxError as e: except SyntaxError as e:
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
return stmts(tree.body) return stmts(tree.body)
def _dedent(source: str) -> str: def _dedent(source: str) -> str:
import textwrap import textwrap
return textwrap.dedent(source) return textwrap.dedent(source)
def literal(source: str) -> Any: def literal(source: str) -> Any:
"""Evaluate a constant expression at conversion time, or raise Unsupported.""" """Evaluate a constant expression at conversion time, or raise Unsupported."""
try: try:
return ast.literal_eval(source.strip()) return ast.literal_eval(source.strip())
except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e: except (ValueError, SyntaxError, TypeError, MemoryError, RecursionError) as e:
raise Unsupported(f"не константа: {source!r}") from e raise Unsupported(f"не константа: {source!r}") from e
def expr(node: ast.AST) -> Expr: def expr(node: ast.AST) -> Expr:
match node: match node:
case ast.Constant(value=v): case ast.Constant(value=v):
if isinstance(v, bytes): if isinstance(v, bytes):
v = v.decode("utf-8", "replace") v = v.decode("utf-8", "replace")
if isinstance(v, complex) or v is Ellipsis: if isinstance(v, complex) or v is Ellipsis:
raise Unsupported("неподдерживаемая константа") raise Unsupported("неподдерживаемая константа")
return const(v) return const(v)
case ast.Name(id=name): case ast.Name(id=name):
return {"k": "name", "id": name} return {"k": "name", "id": name}
case ast.Attribute(value=ast.Name(id="store"), attr=attr): case ast.Attribute(value=ast.Name(id="store"), attr=attr):
return {"k": "name", "id": attr} return {"k": "name", "id": attr}
case ast.Attribute(value=value, attr=attr): case ast.Attribute(value=value, attr=attr):
return {"k": "attr", "o": expr(value), "a": attr} return {"k": "attr", "o": expr(value), "a": attr}
case ast.Subscript(value=value, slice=ast.Slice(lower=lo, upper=hi, step=st)): 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)} return {"k": "slice", "o": expr(value), "lo": _opt(lo), "hi": _opt(hi), "st": _opt(st)}
case ast.Subscript(value=value, slice=index): case ast.Subscript(value=value, slice=index):
return {"k": "sub", "o": expr(value), "i": expr(index)} return {"k": "sub", "o": expr(value), "i": expr(index)}
case ast.BinOp(left=l, op=op, right=r): case ast.BinOp(left=l, op=op, right=r):
if type(op) not in BIN_OPS: if type(op) not in BIN_OPS:
raise Unsupported(f"оператор {type(op).__name__}") raise Unsupported(f"оператор {type(op).__name__}")
return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)} return {"k": "bin", "op": BIN_OPS[type(op)], "l": expr(l), "r": expr(r)}
case ast.BoolOp(op=op, values=values): case ast.BoolOp(op=op, values=values):
return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or", return {"k": "bool", "op": "and" if isinstance(op, ast.And) else "or",
"v": [expr(v) for v in values]} "v": [expr(v) for v in values]}
case ast.UnaryOp(op=op, operand=operand): case ast.UnaryOp(op=op, operand=operand):
return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)} return {"k": "unary", "op": UNARY_OPS[type(op)], "v": expr(operand)}
case ast.Compare(left=l, ops=ops, comparators=rs): case ast.Compare(left=l, ops=ops, comparators=rs):
return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops], return {"k": "cmp", "l": expr(l), "ops": [CMP_OPS[type(o)] for o in ops],
"r": [expr(r) for r in rs]} "r": [expr(r) for r in rs]}
case ast.IfExp(test=t, body=b, orelse=e): case ast.IfExp(test=t, body=b, orelse=e):
return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)} return {"k": "if", "c": expr(t), "t": expr(b), "e": expr(e)}
case ast.List(elts=elts) | ast.Set(elts=elts): case ast.List(elts=elts) | ast.Set(elts=elts):
return {"k": "list", "v": [expr(e) for e in elts]} return {"k": "list", "v": [expr(e) for e in elts]}
case ast.Tuple(elts=elts): case ast.Tuple(elts=elts):
return {"k": "tuple", "v": [expr(e) for e in elts]} return {"k": "tuple", "v": [expr(e) for e in elts]}
case ast.Dict(keys=keys, values=values): case ast.Dict(keys=keys, values=values):
if any(k is None for k in keys): if any(k is None for k in keys):
raise Unsupported("распаковка ** в словаре") raise Unsupported("распаковка ** в словаре")
return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]} return {"k": "dict", "v": [[expr(k), expr(v)] for k, v in zip(keys, values)]}
case ast.Call(func=func, args=args, keywords=kws): 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): if any(isinstance(a, ast.Starred) for a in args) or any(k.arg is None for k in kws):
raise Unsupported("распаковка аргументов *args/**kwargs") raise Unsupported("распаковка аргументов *args/**kwargs")
return {"k": "call", "f": expr(func), "a": [expr(a) for a in args], return {"k": "call", "f": expr(func), "a": [expr(a) for a in args],
"kw": {k.arg: expr(k.value) for k in kws}} "kw": {k.arg: expr(k.value) for k in kws}}
case ast.JoinedStr(values=values): case ast.JoinedStr(values=values):
parts: list[Any] = [] parts: list[Any] = []
for v in values: for v in values:
if isinstance(v, ast.Constant): if isinstance(v, ast.Constant):
parts.append(v.value) parts.append(v.value)
elif isinstance(v, ast.FormattedValue): elif isinstance(v, ast.FormattedValue):
parts.append(expr(v.value)) parts.append(expr(v.value))
return {"k": "fmt", "v": parts} return {"k": "fmt", "v": parts}
case ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs, case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)]): is_async=0)])
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it), | ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
"if": [expr(i) for i in ifs]} ifs=ifs, is_async=0)])
case ast.Lambda(): | ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
raise Unsupported("lambda") is_async=0)])):
raise Unsupported(f"выражение {type(node).__name__}") return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.Lambda():
def _opt(node: ast.AST | None) -> Expr | None: raise Unsupported("lambda")
return None if node is None else expr(node) raise Unsupported(f"выражение {type(node).__name__}")
def target(node: ast.AST) -> Expr: def _opt(node: ast.AST | None) -> Expr | None:
match node: return None if node is None else expr(node)
case ast.Name() | ast.Attribute() | ast.Subscript():
return expr(node)
case ast.Tuple(elts=elts) | ast.List(elts=elts): def target(node: ast.AST) -> Expr:
return {"k": "tuple", "v": [target(e) for e in elts]} match node:
raise Unsupported(f"присваивание в {type(node).__name__}") case ast.Name() | ast.Attribute() | ast.Subscript():
return expr(node)
case ast.Tuple(elts=elts) | ast.List(elts=elts):
def stmts(nodes: list[ast.stmt]) -> list[Stmt]: return {"k": "tuple", "v": [target(e) for e in elts]}
out: list[Stmt] = [] raise Unsupported(f"присваивание в {type(node).__name__}")
for n in nodes:
out.extend(stmt(n))
return out def stmts(nodes: list[ast.stmt]) -> list[Stmt]:
out: list[Stmt] = []
for n in nodes:
def stmt(node: ast.stmt) -> list[Stmt]: out.extend(stmt(n))
match node: return out
case ast.Assign(targets=targets, value=value):
v = expr(value)
return [{"op": "set", "t": target(t), "v": v} for t in targets] def stmt(node: ast.stmt) -> list[Stmt]:
case ast.AnnAssign(target=t, value=value) if value is not None: match node:
return [{"op": "set", "t": target(t), "v": expr(value)}] case ast.Assign(targets=targets, value=value):
case ast.AugAssign(target=t, op=op, value=value): v = expr(value)
if type(op) not in BIN_OPS: return [{"op": "set", "t": target(t), "v": v} for t in targets]
raise Unsupported(f"оператор {type(op).__name__}") case ast.AnnAssign(target=t, value=value) if value is not None:
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}] return [{"op": "set", "t": target(t), "v": expr(value)}]
case ast.Expr(value=ast.Constant()): case ast.AugAssign(target=t, op=op, value=value):
return [] # docstring / bare literal if type(op) not in BIN_OPS:
case ast.Expr(value=value): raise Unsupported(f"оператор {type(op).__name__}")
return [{"op": "eval", "e": expr(value)}] return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
case ast.If(test=test, body=body, orelse=orelse): case ast.Expr(value=ast.Constant()):
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}] return [] # docstring / bare literal
case ast.While(test=test, body=body, orelse=[]): case ast.Expr(value=value):
return [{"op": "while", "c": expr(test), "body": stmts(body)}] return [{"op": "eval", "e": expr(value)}]
case ast.For(target=t, iter=it, body=body, orelse=[]): case ast.If(test=test, body=body, orelse=orelse):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}] return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
case ast.Delete(targets=targets): case ast.While(test=test, body=body, orelse=[]):
return [{"op": "del", "t": target(t)} for t in targets] return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.Pass() | ast.Global() | ast.Nonlocal(): case ast.For(target=t, iter=it, body=body, orelse=[]):
return [] return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.Break(): case ast.Delete(targets=targets):
return [{"op": "break"}] return [{"op": "del", "t": target(t)} for t in targets]
case ast.Continue(): case ast.Pass() | ast.Global() | ast.Nonlocal():
return [{"op": "continue"}] return []
case ast.Import(names=names) | ast.ImportFrom(names=names): case ast.Break():
mod = getattr(node, "module", None) or names[0].name return [{"op": "break"}]
if mod.split(".")[0] in KNOWN_MODULES: case ast.Continue():
return [] return [{"op": "continue"}]
raise Unsupported(f"импорт модуля {mod}") case ast.Import(names=names) | ast.ImportFrom(names=names):
case ast.FunctionDef(name=name) | ast.ClassDef(name=name): mod = getattr(node, "module", None) or names[0].name
raise Unsupported(f"определение {name}()") if mod.split(".")[0] in KNOWN_MODULES:
raise Unsupported(f"оператор {type(node).__name__}") return []
raise Unsupported(f"импорт модуля {mod}")
case ast.FunctionDef(name=name) | ast.ClassDef(name=name):
raise Unsupported(f"определение {name}()")
raise Unsupported(f"оператор {type(node).__name__}")
+5 -2
View File
@@ -7,6 +7,7 @@ unpickler that only allows plain containers — archives are untrusted input.
import io import io
import pickle import pickle
import zlib import zlib
from collections.abc import Callable
from pathlib import Path from pathlib import Path
@@ -65,13 +66,15 @@ def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]:
return index return index
def extract_rpa(path: Path, dest: Path) -> int: def extract_rpa(path: Path, dest: Path, only: Callable[[str], bool] | None = None) -> int:
"""Extract all files of an archive into dest. Returns the number of files.""" """Extract files of an archive into dest (all, or those `only` accepts)."""
index = read_index(path) index = read_index(path)
root = dest.resolve() root = dest.resolve()
count = 0 count = 0
with path.open("rb") as f: with path.open("rb") as f:
for name, entries in index.items(): for name, entries in index.items():
if only is not None and not only(name):
continue
target = (dest / name).resolve() target = (dest / name).resolve()
if not target.is_relative_to(root): if not target.is_relative_to(root):
continue continue
+283 -31
View File
@@ -1,6 +1,8 @@
"""Ren'Py script (.rpy) → PGVN IR.""" """Ren'Py script (.rpy) → PGVN IR."""
import ast import ast
import re
import warnings
from collections import Counter from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any 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", "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size"} "align", "pos", "anchor", "xysize", "size"}
CHANNELS = {"music", "sound", "voice", "audio", "movie"} 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"} BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
@@ -57,6 +59,7 @@ class ScriptData:
defaults: list[list[Any]] = field(default_factory=list) defaults: list[list[Any]] = field(default_factory=list)
init_code: list[Stmt] = field(default_factory=list) # defines + init python, in order init_code: list[Stmt] = field(default_factory=list) # defines + init python, in order
gui: dict[str, Any] = field(default_factory=dict) 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) config: dict[str, Any] = field(default_factory=dict)
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
warnings: Counter = field(default_factory=Counter) warnings: Counter = field(default_factory=Counter)
@@ -73,6 +76,11 @@ class RpyParser:
def __init__(self, data: ScriptData, filename: str): def __init__(self, data: ScriptData, filename: str):
self.d = data self.d = data
self.filename = filename 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 ------------------------------------------------------------- # --- helpers -------------------------------------------------------------
@@ -92,7 +100,7 @@ class RpyParser:
def python(self, source: str, line: Line) -> list[Stmt]: def python(self, source: str, line: Line) -> list[Stmt]:
"""Translate python code statement by statement, skipping unsupported ones.""" """Translate python code statement by statement, skipping unsupported ones."""
try: try:
tree = ast.parse(_dedent(source)) tree = _parse_py(source)
except SyntaxError as e: except SyntaxError as e:
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line)) self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
return [] return []
@@ -114,13 +122,12 @@ class RpyParser:
c = self.cursor(line) c = self.cursor(line)
first = c.peek_word() first = c.peek_word()
if first == "label": if first == "label":
c.keyword("label") name, params = self.label_header(line)
name = c.label_name() if not name.startswith("."):
if not name: self.global_label = name
raise c.error("ожидалось имя метки") current = {"name": self.label_ref(name), "body": self.block(line.block)}
if c.char("("): if params:
self.d.warn("Параметры меток не поддерживаются", self.where(line)) current["params"] = params
current = {"name": name, "body": self.block(line.block)}
labels.append(current) labels.append(current)
continue continue
nodes = self.statement(line, top_level=True) nodes = self.statement(line, top_level=True)
@@ -143,7 +150,15 @@ class RpyParser:
out.append({"op": "if", "branches": branches}) out.append({"op": "if", "branches": branches})
continue continue
if word == "label": 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)) out.extend(self.block(line.block))
i += 1 i += 1
continue continue
@@ -151,6 +166,37 @@ class RpyParser:
i += 1 i += 1
return out 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]: def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]:
branches: list[Node] = [] branches: list[Node] = []
while i < len(lines): while i < len(lines):
@@ -282,11 +328,15 @@ class RpyParser:
def init_python(self, source: str, line: Line) -> None: def init_python(self, source: str, line: Line) -> None:
try: try:
tree = ast.parse(_dedent(source)) tree = _parse_py(source)
except SyntaxError as e: except SyntaxError as e:
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line)) self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
return return
for node in tree.body: 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: if isinstance(node, ast.Assign) and len(node.targets) == 1:
tgt = node.targets[0] tgt = node.targets[0]
name = _assign_name(tgt) name = _assign_name(tgt)
@@ -306,6 +356,21 @@ class RpyParser:
except Unsupported as e: except Unsupported as e:
self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}") 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 ------------------------------------------- # define / default / image / transform -------------------------------------------
def st_define(self, line: Line) -> list[Node]: def st_define(self, line: Line) -> list[Node]:
@@ -499,25 +564,182 @@ class RpyParser:
def atl_image(self, line: Line) -> dict[str, Any] | None: def atl_image(self, line: Line) -> dict[str, Any] | None:
frames: list[dict[str, Any]] = [] frames: list[dict[str, Any]] = []
props: 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) 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() s = c.string()
if s is not None: if s is not None:
img = self.displayable_expr(const(s), child) img = self.displayable_expr(const(s), child)
if img: if img:
frames.append(img) frames.append({"img": img})
continue 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: <image>_<group>_<attribute>
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)) 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: if props:
img["props"] = {**img.get("props", {}), **props} node["at"].append({"props": props})
return img return [node]
def st_transform(self, line: Line) -> list[Node]: def st_transform(self, line: Line) -> list[Node]:
c = self.cursor(line) c = self.cursor(line)
@@ -588,6 +810,10 @@ class RpyParser:
fname = _dotted_name(e["f"]) fname = _dotted_name(e["f"])
if fname in ("Position", "Transform"): if fname in ("Position", "Transform"):
return {"props": _static_props(e["kw"])} 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: if fname in self.d.transforms:
return {"props": self.d.transforms[fname], "name": fname} return {"props": self.d.transforms[fname], "name": fname}
if e and e["k"] == "name" and e["id"] not in self.d.transforms: 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)) self.d.warn("Не поддерживается: screen", self.where(line))
return [] return []
if c.keyword("text"): if c.keyword("text"):
text = c.string() or "" text = c.string()
node: Node = {"op": "show", "img": {"text": text}} 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) self.show_clauses(c, line, node)
return [node] return [node]
node = {"op": "show", "img": self.image_spec(c, line)} 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) 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, return {"t": "fade", "out": out, "hold": hold, "in": fin,
"color": kw.get("color") or "#000000"} "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"): if fname in ("MoveTransition", "Move"):
return {"t": "move", "d": nums[0] if nums else 0.5} return {"t": "move", "d": nums[0] if nums else 0.5}
if fname in TRANSITIONS: if fname in TRANSITIONS:
@@ -690,8 +922,6 @@ class RpyParser:
c = self.cursor(line) c = self.cursor(line)
c.keyword(op) c.keyword(op)
ch = c.word() or "music" 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)} node: Node = {"op": op, "ch": ch, "files": self.audio_files(c, line)}
while not c.eol(): while not c.eol():
if c.keyword("fadein"): if c.keyword("fadein"):
@@ -737,7 +967,7 @@ class RpyParser:
c.keyword("jump") c.keyword("jump")
if c.keyword("expression"): if c.keyword("expression"):
return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}] 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]: def st_call(self, line: Line) -> list[Node]:
c = self.cursor(line) c = self.cursor(line)
@@ -748,10 +978,12 @@ class RpyParser:
if c.keyword("expression"): if c.keyword("expression"):
node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line, node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line,
const(""))} const(""))}
c.keyword("pass")
else: else:
node = {"op": "call", "target": c.label_name()} node = {"op": "call", "target": self.label_ref(c.label_name() or "")}
if c.peek("("): args = self.call_args(c, line)
self.d.warn("Аргументы call не поддерживаются", self.where(line)) if args:
node["args"] = args
return [node] return [node]
def st_return(self, line: Line) -> list[Node]: def st_return(self, line: Line) -> list[Node]:
@@ -791,11 +1023,19 @@ class RpyParser:
def st_menu(self, line: Line) -> list[Node]: def st_menu(self, line: Line) -> list[Node]:
node: Node = {"op": "menu", "items": [], "say": None} 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: for child in line.block:
c = self.cursor(child) c = self.cursor(child)
if c.keyword("set"): if c.keyword("set"):
node["set"] = self.expr(c.rest(), child) node["set"] = self.expr(c.rest(), child)
continue continue
if c.keyword("with"):
continue
text = c.string() text = c.string()
if text is not None and child.text.rstrip().endswith(":"): if text is not None and child.text.rstrip().endswith(":"):
cond = None cond = None
@@ -813,7 +1053,7 @@ class RpyParser:
else: else:
self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}") self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}")
self.d.stats["menu"] += 1 self.d.stats["menu"] += 1
return [node] return out + [node]
# say ------------------------------------------------------------------------------ # say ------------------------------------------------------------------------------
@@ -871,6 +1111,11 @@ class RpyParser:
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} _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", _MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"} "flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
@@ -880,6 +1125,13 @@ def _dedent(source: str) -> str:
return textwrap.dedent(source) 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: def _dotted_name(e: Expr) -> str:
if e["k"] == "name": if e["k"] == "name":
return e["id"] return e["id"]
+48
View File
@@ -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())
+99 -99
View File
@@ -1,99 +1,99 @@
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import ( from sqlalchemy import (
ARRAY, ARRAY,
BigInteger, BigInteger,
Boolean, Boolean,
DateTime, DateTime,
ForeignKey, ForeignKey,
Integer, Integer,
String, String,
Text, Text,
UniqueConstraint, UniqueConstraint,
func, func,
) )
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base from app.db import Base
class Role(Base): class Role(Base):
__tablename__ = "roles" __tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True) name: Mapped[str] = mapped_column(String(64), unique=True)
permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) permissions: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
is_system: Mapped[bool] = mapped_column(Boolean, default=False) is_system: Mapped[bool] = mapped_column(Boolean, default=False)
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True) username: Mapped[str] = mapped_column(String(64), unique=True)
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id")) role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
is_active: Mapped[bool] = mapped_column(Boolean, default=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True)
must_change_password: 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) must_change_username: Mapped[bool] = mapped_column(Boolean, default=False)
token_version: Mapped[int] = mapped_column(Integer, default=0) token_version: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
role: Mapped[Role] = relationship(lazy="joined") role: Mapped[Role] = relationship(lazy="joined")
@property @property
def permissions(self) -> list[str]: def permissions(self) -> list[str]:
return sorted(self.role.permissions) if self.is_active else [] return sorted(self.role.permissions) if self.is_active else []
class Game(Base): class Game(Base):
__tablename__ = "games" __tablename__ = "games"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(255)) title: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list) tags: Mapped[list[str]] = mapped_column(ARRAY(String), default=list)
engine: Mapped[str] = mapped_column(String(32), default="renpy") engine: Mapped[str] = mapped_column(String(32), default="renpy")
cover_hash: Mapped[str | None] = mapped_column(String(80)) cover_hash: Mapped[str | None] = mapped_column(String(80))
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL")) 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_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
created_by: Mapped[User | None] = relationship(lazy="joined") created_by: Mapped[User | None] = relationship(lazy="joined")
class GameVersion(Base): class GameVersion(Base):
__tablename__ = "game_versions" __tablename__ = "game_versions"
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
game_id: Mapped[int] = mapped_column(ForeignKey("games.id", ondelete="CASCADE"), index=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")) 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")) owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
name: Mapped[str] = mapped_column(String(255)) name: Mapped[str] = mapped_column(String(255))
description: Mapped[str] = mapped_column(Text, default="") description: Mapped[str] = mapped_column(Text, default="")
# uploading -> queued -> converting -> ready | failed # uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True) status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
progress: Mapped[str] = mapped_column(String(255), default="") progress: Mapped[str] = mapped_column(String(255), default="")
upload_name: Mapped[str | None] = mapped_column(String(255)) upload_name: Mapped[str | None] = mapped_column(String(255))
manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB) manifest: Mapped[dict[str, Any] | None] = mapped_column(JSONB)
report: 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) size_bytes: Mapped[int] = mapped_column(BigInteger, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
owner: Mapped[User | None] = relationship(lazy="joined") owner: Mapped[User | None] = relationship(lazy="joined")
class Save(Base): class Save(Base):
__tablename__ = "saves" __tablename__ = "saves"
__table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),) __table_args__ = (UniqueConstraint("user_id", "version_id", "slot"),)
id: Mapped[int] = mapped_column(primary_key=True) id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE")) user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE")) version_id: Mapped[int] = mapped_column(ForeignKey("game_versions.id", ondelete="CASCADE"))
slot: Mapped[str] = mapped_column(String(32)) slot: Mapped[str] = mapped_column(String(32))
title: Mapped[str] = mapped_column(String(255), default="") title: Mapped[str] = mapped_column(String(255), default="")
data: Mapped[dict[str, Any]] = mapped_column(JSONB) data: Mapped[dict[str, Any]] = mapped_column(JSONB)
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now() DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
) )
+201 -201
View File
@@ -1,201 +1,201 @@
from typing import Annotated, Any from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select from sqlalchemy import select
from app.deps import DB, CurrentUser, has_perm, require from app.deps import DB, CurrentUser, has_perm, require
from app.models import GameVersion, Save, User from app.models import GameVersion, Save, User
from app.routers.games import get_game from app.routers.games import get_game
from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut from app.schemas import SaveDetailOut, SaveIn, SaveOut, VersionDetailOut, VersionIn, VersionOut
from app.uploads import remove_upload, upload_path from app.uploads import remove_upload, upload_path
router = APIRouter(prefix="/api", tags=["versions"]) router = APIRouter(prefix="/api", tags=["versions"])
Player = Annotated[User, Depends(require("games.play"))] Player = Annotated[User, Depends(require("games.play"))]
Uploader = Annotated[User, Depends(require("games.upload"))] Uploader = Annotated[User, Depends(require("games.upload"))]
ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz") ARCHIVE_EXTS = (".zip", ".7z", ".rar", ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tar.xz")
def can_see(user: User, v: GameVersion) -> bool: def can_see(user: User, v: GameVersion) -> bool:
return v.status == "ready" or v.owner_id == user.id or can_manage(user, v) return v.status == "ready" or v.owner_id == user.id or can_manage(user, v)
def can_manage(user: User, v: GameVersion) -> bool: def can_manage(user: User, v: GameVersion) -> bool:
return ( return (
v.owner_id == user.id v.owner_id == user.id
or has_perm(user, "versions.manage_any") or has_perm(user, "versions.manage_any")
or has_perm(user, "games.delete") or has_perm(user, "games.delete")
) )
async def get_version(db: DB, user: User, version_id: int) -> GameVersion: async def get_version(db: DB, user: User, version_id: int) -> GameVersion:
v = await db.get(GameVersion, version_id) v = await db.get(GameVersion, version_id)
if not v or not can_see(user, v): if not v or not can_see(user, v):
raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена") raise HTTPException(status.HTTP_404_NOT_FOUND, "Версия не найдена")
return v return v
@router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201) @router.post("/games/{game_id}/upload", response_model=VersionOut, status_code=201)
async def upload_version( async def upload_version(
game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал" game_id: int, request: Request, user: Uploader, db: DB, filename: str, name: str = "Оригинал"
) -> GameVersion: ) -> GameVersion:
"""Stream a raw archive body to disk and queue it for conversion.""" """Stream a raw archive body to disk and queue it for conversion."""
game = await get_game(db, game_id) game = await get_game(db, game_id)
if not filename.lower().endswith(ARCHIVE_EXTS): if not filename.lower().endswith(ARCHIVE_EXTS):
raise HTTPException(status.HTTP_400_BAD_REQUEST, raise HTTPException(status.HTTP_400_BAD_REQUEST,
f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}") f"Поддерживаемые архивы: {', '.join(ARCHIVE_EXTS)}")
version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал", version = GameVersion(game_id=game.id, owner_id=user.id, name=name.strip() or "Оригинал",
upload_name=filename, status="uploading") upload_name=filename, status="uploading")
db.add(version) db.add(version)
await db.commit() await db.commit()
path = upload_path(version.id) path = upload_path(version.id)
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
size = 0 size = 0
try: try:
with path.open("wb") as f: with path.open("wb") as f:
async for chunk in request.stream(): async for chunk in request.stream():
f.write(chunk) f.write(chunk)
size += len(chunk) size += len(chunk)
except Exception: except Exception:
remove_upload(version.id) remove_upload(version.id)
await db.delete(version) await db.delete(version)
await db.commit() await db.commit()
raise raise
if size == 0: if size == 0:
remove_upload(version.id) remove_upload(version.id)
await db.delete(version) await db.delete(version)
await db.commit() await db.commit()
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Пустой файл")
version.status = "queued" version.status = "queued"
version.progress = "В очереди на конвертацию" version.progress = "В очереди на конвертацию"
version.size_bytes = size version.size_bytes = size
await db.commit() await db.commit()
return version return version
@router.get("/versions/{version_id}", response_model=VersionDetailOut) @router.get("/versions/{version_id}", response_model=VersionDetailOut)
async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion: async def version_detail(version_id: int, user: Player, db: DB) -> GameVersion:
return await get_version(db, user, version_id) return await get_version(db, user, version_id)
@router.get("/versions/{version_id}/manifest") @router.get("/versions/{version_id}/manifest")
async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]: async def version_manifest(version_id: int, user: Player, db: DB) -> dict[str, Any]:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if v.status != "ready" or not v.manifest: if v.status != "ready" or not v.manifest:
raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова") raise HTTPException(status.HTTP_409_CONFLICT, "Версия ещё не готова")
return v.manifest return v.manifest
@router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201) @router.post("/versions/{version_id}/fork", response_model=VersionOut, status_code=201)
async def fork_version(version_id: int, body: VersionIn, async def fork_version(version_id: int, body: VersionIn,
user: Annotated[User, Depends(require("games.play", "versions.fork"))], user: Annotated[User, Depends(require("games.play", "versions.fork"))],
db: DB) -> GameVersion: db: DB) -> GameVersion:
parent = await get_version(db, user, version_id) parent = await get_version(db, user, version_id)
if parent.status != "ready": if parent.status != "ready":
raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию") raise HTTPException(status.HTTP_409_CONFLICT, "Форкнуть можно только готовую версию")
fork = GameVersion( fork = GameVersion(
game_id=parent.game_id, game_id=parent.game_id,
parent_id=parent.id, parent_id=parent.id,
owner_id=user.id, owner_id=user.id,
name=body.name.strip(), name=body.name.strip(),
description=body.description, description=body.description,
status="ready", status="ready",
manifest=parent.manifest, manifest=parent.manifest,
report=parent.report, report=parent.report,
size_bytes=parent.size_bytes, size_bytes=parent.size_bytes,
) )
db.add(fork) db.add(fork)
await db.commit() await db.commit()
return fork return fork
@router.patch("/versions/{version_id}", response_model=VersionOut) @router.patch("/versions/{version_id}", response_model=VersionOut)
async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion: async def update_version(version_id: int, body: VersionIn, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if not can_manage(user, v): if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
v.name = body.name.strip() v.name = body.name.strip()
v.description = body.description v.description = body.description
await db.commit() await db.commit()
return v return v
@router.post("/versions/{version_id}/retry", response_model=VersionOut) @router.post("/versions/{version_id}/retry", response_model=VersionOut)
async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion: async def retry_version(version_id: int, user: CurrentUser, db: DB) -> GameVersion:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if not can_manage(user, v): if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status != "failed" or not upload_path(v.id).exists(): if v.status != "failed" or not upload_path(v.id).exists():
raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна") raise HTTPException(status.HTTP_409_CONFLICT, "Повторная конвертация невозможна")
v.status = "queued" v.status = "queued"
v.progress = "В очереди на конвертацию" v.progress = "В очереди на конвертацию"
await db.commit() await db.commit()
return v return v
@router.delete("/versions/{version_id}", status_code=204) @router.delete("/versions/{version_id}", status_code=204)
async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None: async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
v = await get_version(db, user, version_id) v = await get_version(db, user, version_id)
if not can_manage(user, v): if not can_manage(user, v):
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию") raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
if v.status == "converting": if v.status in ("converting", "importing"):
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется") raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
await db.delete(v) await db.delete(v)
await db.commit() await db.commit()
remove_upload(version_id) remove_upload(version_id)
# --- saves ----------------------------------------------------------------- # --- saves -----------------------------------------------------------------
def _check_slot(slot: str) -> str: 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): 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, "Неверный слот") raise HTTPException(status.HTTP_400_BAD_REQUEST, "Неверный слот")
return slot return slot
@router.get("/versions/{version_id}/saves", response_model=list[SaveOut]) @router.get("/versions/{version_id}/saves", response_model=list[SaveOut])
async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]: async def list_saves(version_id: int, user: Player, db: DB) -> list[Save]:
await get_version(db, user, version_id) await get_version(db, user, version_id)
return list((await db.scalars( return list((await db.scalars(
select(Save).where(Save.user_id == user.id, Save.version_id == version_id) select(Save).where(Save.user_id == user.id, Save.version_id == version_id)
.order_by(Save.updated_at.desc()) .order_by(Save.updated_at.desc())
)).all()) )).all())
async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None: async def _find_save(db: DB, user: User, version_id: int, slot: str) -> Save | None:
return await db.scalar(select(Save).where( return await db.scalar(select(Save).where(
Save.user_id == user.id, Save.version_id == version_id, Save.slot == _check_slot(slot))) 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) @router.get("/versions/{version_id}/saves/{slot}", response_model=SaveDetailOut)
async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save: async def get_save(version_id: int, slot: str, user: Player, db: DB) -> Save:
save = await _find_save(db, user, version_id, slot) save = await _find_save(db, user, version_id, slot)
if not save: if not save:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено") raise HTTPException(status.HTTP_404_NOT_FOUND, "Сохранение не найдено")
return save return save
@router.put("/versions/{version_id}/saves/{slot}", response_model=SaveOut) @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: async def put_save(version_id: int, slot: str, body: SaveIn, user: Player, db: DB) -> Save:
await get_version(db, user, version_id) await get_version(db, user, version_id)
save = await _find_save(db, user, version_id, slot) save = await _find_save(db, user, version_id, slot)
if save is None: if save is None:
save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255], save = Save(user_id=user.id, version_id=version_id, slot=slot, title=body.title[:255],
data=body.data) data=body.data)
db.add(save) db.add(save)
else: else:
save.title = body.title[:255] save.title = body.title[:255]
save.data = body.data save.data = body.data
await db.commit() await db.commit()
await db.refresh(save) await db.refresh(save)
return save return save
@router.delete("/versions/{version_id}/saves/{slot}", status_code=204) @router.delete("/versions/{version_id}/saves/{slot}", status_code=204)
async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None: async def delete_save(version_id: int, slot: str, user: Player, db: DB) -> None:
save = await _find_save(db, user, version_id, slot) save = await _find_save(db, user, version_id, slot)
if save: if save:
await db.delete(save) await db.delete(save)
await db.commit() await db.commit()
+21 -13
View File
@@ -3,6 +3,7 @@
import asyncio import asyncio
import logging import logging
import shutil import shutil
from pathlib import Path
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -41,34 +42,39 @@ async def claim() -> GameVersion | None:
async def process(v: 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() 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: 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: try:
result = await asyncio.to_thread(convert_archive, upload_path(v.id), v.upload_name or "", result = await asyncio.to_thread(convert_archive, source, filename, work, progress)
work, progress)
except Exception as e: # noqa: BLE001 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: async with SessionLocal() as db:
version = await db.get(GameVersion, v.id) version = await db.get(GameVersion, version_id)
if version: if version:
version.status = "failed" version.status = "failed"
version.progress = f"Ошибка: {e}"[:255] version.progress = f"Ошибка: {e}"[:255]
version.report = {"error": str(e)} version.report = {"error": str(e)}
await db.commit() await db.commit()
return return False
finally: finally:
shutil.rmtree(work, ignore_errors=True) shutil.rmtree(work, ignore_errors=True)
async with SessionLocal() as db: 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 if version is None: # deleted while converting
return return True
version.status = "ready" version.status = "ready"
version.progress = "" version.progress = ""
version.manifest = result.manifest version.manifest = result.manifest
@@ -81,9 +87,11 @@ async def process(v: GameVersion) -> None:
read_bytes(result.cover_source)) read_bytes(result.cover_source))
except OSError: except OSError:
pass pass
if game and result.title and game.title == source.name:
game.title = result.title
await db.commit() await db.commit()
remove_upload(v.id) # sources are not kept once converted log.info("version %s ready: %s", version_id, result.report.get("media"))
log.info("version %s ready: %s", v.id, result.report.get("media")) return True
async def main() -> None: async def main() -> None:
+41
View File
@@ -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"] assert [lbl["name"] for lbl in script["labels"]] == ["start", "walk", "stay_home", "ending"]
if shutil.which("ffmpeg"): if shutil.which("ffmpeg"):
assert "audio/theme.ogg" in files 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}
+118 -118
View File
@@ -1,118 +1,118 @@
export interface Role { export interface Role {
id: number; id: number;
name: string; name: string;
permissions: string[]; permissions: string[];
is_system: boolean; is_system: boolean;
} }
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
role: Role; role: Role;
is_active: boolean; is_active: boolean;
must_change_password: boolean; must_change_password: boolean;
must_change_username: boolean; must_change_username: boolean;
created_at: string; created_at: string;
} }
export interface Me extends User { export interface Me extends User {
permissions: string[]; permissions: string[];
} }
export interface Version { export interface Version {
id: number; id: number;
game_id: number; game_id: number;
parent_id: number | null; parent_id: number | null;
owner: { id: number; username: string } | null; owner: { id: number; username: string } | null;
name: string; name: string;
description: string; description: string;
status: "uploading" | "queued" | "converting" | "ready" | "failed"; status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
progress: string; progress: string;
size_bytes: number; size_bytes: number;
created_at: string; created_at: string;
report?: Record<string, any> | null; report?: Record<string, any> | null;
} }
export interface Game { export interface Game {
id: number; id: number;
title: string; title: string;
description: string; description: string;
tags: string[]; tags: string[];
engine: string; engine: string;
cover_hash: string | null; cover_hash: string | null;
created_at: string; created_at: string;
} }
export interface GameDetail extends Game { export interface GameDetail extends Game {
versions: Version[]; versions: Version[];
} }
export class ApiError extends Error { export class ApiError extends Error {
constructor(public status: number, message: string) { constructor(public status: number, message: string) {
super(message); super(message);
} }
} }
async function request<T>(method: string, url: string, body?: unknown): Promise<T> { async function request<T>(method: string, url: string, body?: unknown): Promise<T> {
const r = await fetch(url, { const r = await fetch(url, {
method, method,
credentials: "same-origin", credentials: "same-origin",
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined, headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined, body: body !== undefined ? JSON.stringify(body) : undefined,
}); });
if (!r.ok) { if (!r.ok) {
let msg = r.statusText; let msg = r.statusText;
try { try {
const data = await r.json(); const data = await r.json();
msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail); msg = typeof data.detail === "string" ? data.detail : JSON.stringify(data.detail);
} catch { } catch {
/* not json */ /* not json */
} }
if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized")); if (r.status === 401) window.dispatchEvent(new Event("pg-unauthorized"));
throw new ApiError(r.status, msg); throw new ApiError(r.status, msg);
} }
return r.status === 204 ? (undefined as T) : r.json(); return r.status === 204 ? (undefined as T) : r.json();
} }
export const api = { export const api = {
get: <T>(url: string) => request<T>("GET", url), get: <T>(url: string) => request<T>("GET", url),
post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}), post: <T>(url: string, body?: unknown) => request<T>("POST", url, body ?? {}),
put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}), put: <T>(url: string, body?: unknown) => request<T>("PUT", url, body ?? {}),
patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}), patch: <T>(url: string, body?: unknown) => request<T>("PATCH", url, body ?? {}),
del: (url: string) => request<void>("DELETE", url), del: (url: string) => request<void>("DELETE", url),
}; };
/** Upload a raw body with progress (fetch has no upload progress). */ /** Upload a raw body with progress (fetch has no upload progress). */
export function uploadRaw<T>(method: string, url: string, file: Blob, export function uploadRaw<T>(method: string, url: string, file: Blob,
onProgress: (fraction: number) => void): Promise<T> { onProgress: (fraction: number) => void): Promise<T> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open(method, url); xhr.open(method, url);
xhr.setRequestHeader("Content-Type", "application/octet-stream"); xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total);
xhr.onload = () => { xhr.onload = () => {
let data: any = null; let data: any = null;
try { try {
data = JSON.parse(xhr.responseText); data = JSON.parse(xhr.responseText);
} catch { } catch {
/* empty */ /* empty */
} }
if (xhr.status >= 200 && xhr.status < 300) resolve(data); if (xhr.status >= 200 && xhr.status < 300) resolve(data);
else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText)); else reject(new ApiError(xhr.status, data?.detail ?? xhr.statusText));
}; };
xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка")); xhr.onerror = () => reject(new ApiError(0, "Сетевая ошибка"));
xhr.send(file); xhr.send(file);
}); });
} }
export function formatSize(bytes: number): string { export function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`; if (bytes < 1024) return `${bytes} Б`;
const units = ["КБ", "МБ", "ГБ", "ТБ"]; const units = ["КБ", "МБ", "ГБ", "ТБ"];
let v = bytes / 1024; let v = bytes / 1024;
let i = 0; let i = 0;
while (v >= 1024 && i < units.length - 1) { while (v >= 1024 && i < units.length - 1) {
v /= 1024; v /= 1024;
i++; i++;
} }
return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`; return `${v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`;
} }
+3 -2
View File
@@ -7,6 +7,7 @@ const STATUS: Record<Version["status"], string> = {
uploading: "загружается", uploading: "загружается",
queued: "в очереди", queued: "в очереди",
converting: "конвертируется", converting: "конвертируется",
importing: "импортируется",
ready: "готова", ready: "готова",
failed: "ошибка", failed: "ошибка",
}; };
@@ -32,7 +33,7 @@ export function GamePage() {
}, [load]); }, [load]);
// poll while something is being converted // 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(() => { useEffect(() => {
if (!busy) return; if (!busy) return;
const t = setInterval(() => void load(), 2000); const t = setInterval(() => void load(), 2000);
@@ -154,7 +155,7 @@ function VersionRow(props: {
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>} {v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>} {(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>} {v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && v.status !== "converting" && <button className="danger" onClick={props.onDelete}>Удалить</button>} {props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>}
</div> </div>
</div> </div>
{report !== undefined && <Report report={report} />} {report !== undefined && <Report report={report} />}
+126 -124
View File
@@ -1,124 +1,126 @@
// Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots. // Audio channels on top of HTMLAudioElement: music loops with fades, sound/voice one-shots.
export interface Volumes { export interface Volumes {
music: number; music: number;
sound: number; sound: number;
voice: number; voice: number;
} }
interface Channel { interface Channel {
el: HTMLAudioElement | null; el: HTMLAudioElement | null;
file: string | null; file: string | null;
loop: boolean; loop: boolean;
queue: string[]; queue: string[];
} }
function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) { function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, done?: () => void) {
if (seconds <= 0) { if (seconds <= 0) {
el.volume = to; el.volume = to;
done?.(); done?.();
return; return;
} }
const start = performance.now(); const start = performance.now();
const step = () => { const step = () => {
const t = Math.min(1, (performance.now() - start) / (seconds * 1000)); const t = Math.min(1, (performance.now() - start) / (seconds * 1000));
el.volume = Math.max(0, Math.min(1, from + (to - from) * t)); el.volume = Math.max(0, Math.min(1, from + (to - from) * t));
if (t < 1) requestAnimationFrame(step); if (t < 1) requestAnimationFrame(step);
else done?.(); else done?.();
}; };
requestAnimationFrame(step); requestAnimationFrame(step);
} }
export class AudioSystem { export class AudioSystem {
channels = new Map<string, Channel>(); channels = new Map<string, Channel>();
constructor(public volumes: Volumes, private url: (file: string) => string | null) {} constructor(public volumes: Volumes, private url: (file: string) => string | null,
private registered: Record<string, { mixer: string; loop: boolean }> = {}) {}
private group(ch: string): keyof Volumes {
if (ch === "music") return "music"; private group(ch: string): keyof Volumes {
if (ch === "voice") return "voice"; const mixer = this.registered[ch]?.mixer ?? ch;
return "sound"; 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) { private channel(name: string): Channel {
c = { el: null, file: null, loop: false, queue: [] }; let c = this.channels.get(name);
this.channels.set(name, c); if (!c) {
} c = { el: null, file: null, loop: false, queue: [] };
return c; 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); play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
const loop = opts.loop ?? ch === "music"; ifChanged?: boolean }) {
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return; const c = this.channel(ch);
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0)); const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music";
c.queue = files.slice(1); if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
c.loop = loop; this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
this.start(ch, c, files[0], opts.fadein ?? 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) { queue(ch: string, files: string[], loop?: boolean | null) {
this.play(ch, files, { loop }); const c = this.channel(ch);
return; if (!c.el || c.el.paused) {
} this.play(ch, files, { loop });
c.queue.push(...files); return;
if (loop != null) c.loop = loop; }
} 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); private start(ch: string, c: Channel, file: string | undefined, fadein: number) {
if (!src) { if (!file) return;
console.warn("[pgvn] нет аудиофайла", file); const src = this.url(file);
return; if (!src) {
} console.warn("[pgvn] нет аудиофайла", file);
const el = new Audio(src); return;
const target = this.volumes[this.group(ch)]; }
el.volume = fadein > 0 ? 0 : target; const el = new Audio(src);
el.loop = c.loop && c.queue.length === 0; const target = this.volumes[this.group(ch)];
el.onended = () => { el.volume = fadein > 0 ? 0 : target;
if (c.el !== el) return; el.loop = c.loop && c.queue.length === 0;
const next = c.queue.shift(); el.onended = () => {
if (next) { if (c.el !== el) return;
if (c.loop) c.queue.push(next); const next = c.queue.shift();
this.start(ch, c, next, 0); if (next) {
} else { if (c.loop) c.queue.push(next);
c.el = null; this.start(ch, c, next, 0);
c.file = null; } else {
} c.el = null;
}; c.file = null;
c.el = el; }
c.file = file; };
el.play().catch(() => console.warn("[pgvn] браузер заблокировал автозапуск звука")); c.el = el;
if (fadein > 0) fade(el, 0, target, fadein); 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; stop(ch: string, fadeout = 0) {
const el = c.el; const c = this.channels.get(ch);
c.el = null; if (!c?.el) return;
c.file = null; const el = c.el;
c.queue = []; c.el = null;
fade(el, el.volume, 0, fadeout, () => el.pause()); c.file = null;
} c.queue = [];
fade(el, el.volume, 0, fadeout, () => el.pause());
stopAll() { }
for (const ch of this.channels.keys()) this.stop(ch);
} 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)]; 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; current(ch: string): { file: string; loop: boolean } | null {
} const c = this.channels.get(ch);
} return c?.file ? { file: c.file, loop: c.loop } : null;
}
}
+1357 -1289
View File
File diff suppressed because it is too large. Load diff
+366 -287
View File
@@ -1,287 +1,366 @@
// DOM renderer for the image layer: one absolutely positioned element per shown tag. // 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. // The stage has the game's native resolution and is scaled with a CSS transform.
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types"; import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = { const POSITIONS: Record<string, Record<string, number>> = {
left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 }, left: { xpos: 0, xanchor: 0, ypos: 1, yanchor: 1 },
right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 }, right: { xpos: 1, xanchor: 1, ypos: 1, yanchor: 1 },
center: { xpos: 0.5, xanchor: 0.5, 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 }, default: { xpos: 0.5, xanchor: 0.5, ypos: 1, yanchor: 1 },
reset: { 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 }, truecenter: { xpos: 0.5, xanchor: 0.5, ypos: 0.5, yanchor: 0.5 },
topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 }, topleft: { xpos: 0, xanchor: 0, ypos: 0, yanchor: 0 },
topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 }, topright: { xpos: 1, xanchor: 1, ypos: 0, yanchor: 0 },
top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 }, top: { xpos: 0.5, xanchor: 0.5, ypos: 0, yanchor: 0 },
offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 }, offscreenleft: { xpos: 0, xanchor: 1, ypos: 1, yanchor: 1 },
offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 }, offscreenright: { xpos: 1, xanchor: 0, ypos: 1, yanchor: 1 },
}; };
export interface ResolvedImage { export interface ResolvedImage {
def: ImageDef; def: ImageDef;
name: string[]; name: string[];
} }
export interface RendererHost { export interface RendererHost {
url(file: string): string | null; url(file: string): string | null;
resolveRef(name: string): ImageDef | null; resolveRef(name: string): ImageDef | null;
evalCond(e: any): boolean; evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null; transform(name: string): Record<string, any> | null;
} text(e: any): string;
}
interface Entry extends ShownImage {
el: HTMLElement; interface Entry extends ShownImage {
} el: HTMLElement;
}
export class Renderer {
readonly stage: HTMLDivElement; export class Renderer {
readonly master: HTMLDivElement; readonly stage: HTMLDivElement;
private overlay: HTMLDivElement; readonly master: HTMLDivElement;
private shown = new Map<string, Entry>(); private overlay: HTMLDivElement;
private committed: HTMLElement | null = null; private shown = new Map<string, Entry>();
fast = false; private committed: HTMLElement | null = null;
fast = false;
constructor(parent: HTMLElement, public width: number, public height: number,
private host: RendererHost) { constructor(parent: HTMLElement, public width: number, public height: number,
this.stage = document.createElement("div"); private host: RendererHost) {
this.stage.className = "pg-stage"; this.stage = document.createElement("div");
this.stage.style.width = `${width}px`; this.stage.className = "pg-stage";
this.stage.style.height = `${height}px`; this.stage.style.width = `${width}px`;
this.master = document.createElement("div"); this.stage.style.height = `${height}px`;
this.master.className = "pg-layer"; this.master = document.createElement("div");
this.overlay = document.createElement("div"); this.master.className = "pg-layer";
this.overlay.className = "pg-layer pg-transition"; this.overlay = document.createElement("div");
this.stage.append(this.master, this.overlay); this.overlay.className = "pg-layer pg-transition";
parent.append(this.stage); 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); fit(container: HTMLElement) {
this.stage.style.transform = `translate(-50%, -50%) scale(${s})`; const s = Math.min(container.clientWidth / this.width, container.clientHeight / this.height);
} this.stage.style.transform = `translate(-50%, -50%) scale(${s})`;
}
// --- scene state ---------------------------------------------------------
// --- scene state ---------------------------------------------------------
scene() {
for (const e of this.shown.values()) e.el.remove(); scene() {
this.shown.clear(); 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); show(img: ResolvedImage, tag: string, at: AtSpec[], opts: { zorder?: number; behind?: string[] } = {}) {
const atSpecs = at.length ? at : prev?.at ?? []; const prev = this.shown.get(tag);
const el = this.build(img.def); const atSpecs = at.length ? at : prev?.at ?? [];
el.classList.add("pg-image"); const el = this.build(img.def);
this.place(el, img.def, atSpecs); el.classList.add("pg-image");
const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el }; this.place(el, img.def, atSpecs);
if (prev) { const entry: Entry = { tag, name: img.name, at: atSpecs, zorder: opts.zorder ?? prev?.zorder ?? 0, el };
prev.el.replaceWith(el); if (prev) {
} else { prev.el.replaceWith(el);
const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean); } else {
const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el; const behind = opts.behind?.map((t) => this.shown.get(t)?.el).find(Boolean);
this.master.insertBefore(el, behind ?? later ?? null); const later = [...this.shown.values()].find((e) => e.zorder > entry.zorder)?.el;
} this.master.insertBefore(el, behind ?? later ?? null);
this.shown.set(tag, entry); }
} this.shown.set(tag, entry);
}
hide(tag: string) {
const e = this.shown.get(tag); hide(tag: string) {
if (!e) return; const e = this.shown.get(tag);
e.el.remove(); if (!e) return;
this.shown.delete(tag); e.el.remove();
} this.shown.delete(tag);
}
isShown(tag: string) {
return this.shown.has(tag); isShown(tag: string) {
} return this.shown.has(tag);
}
attrsOf(tag: string): string[] | null {
return this.shown.get(tag)?.name ?? null; 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 })); snapshot(): ShownImage[] {
} return [...this.shown.values()].map(({ tag, name, at, zorder }) => ({ tag, name, at, zorder }));
}
// --- displayables ------------------------------------------------------------
// --- displayables ------------------------------------------------------------
private build(def: ImageDef, depth = 0): HTMLElement {
if (depth > 8) return document.createElement("div"); private build(def: ImageDef, depth = 0): HTMLElement {
if (def.ref) { if (depth > 8) return document.createElement("div");
const ref = this.host.resolveRef(def.ref); if (def.ref) {
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1); 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 (def.switch) {
if (hit) return this.build(hit[1], depth + 1); 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"); if (def.color) {
d.style.background = def.color; const d = document.createElement("div");
d.style.width = `${this.width}px`; d.style.background = def.color;
d.style.height = `${this.height}px`; d.style.width = `${this.width}px`;
return d; d.style.height = `${this.height}px`;
} return d;
if (def.composite) { }
const d = document.createElement("div"); if (def.composite) {
d.style.position = "relative"; const d = document.createElement("div");
if (def.size) { d.style.position = "relative";
d.style.width = `${def.size[0]}px`; if (def.size) {
d.style.height = `${def.size[1]}px`; 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); for (const layer of def.composite) {
child.style.position = "absolute"; const child = this.build(layer.img, depth + 1);
child.style.left = `${layer.pos[0]}px`; child.style.position = "absolute";
child.style.top = `${layer.pos[1]}px`; child.style.left = `${layer.pos[0]}px`;
d.append(child); child.style.top = `${layer.pos[1]}px`;
} d.append(child);
return d; }
} return d;
if (def.movie) { }
const v = document.createElement("video"); if (def.movie) {
const src = this.host.url(def.movie); const v = document.createElement("video");
if (src) v.src = src; const src = this.host.url(def.movie);
v.autoplay = true; if (src) v.src = src;
v.loop = true; v.autoplay = true;
v.muted = true; v.loop = true;
v.playsInline = true; v.muted = true;
return v; v.playsInline = true;
} return v;
if (def.text !== undefined) { }
const d = document.createElement("div"); if (def.text !== undefined || def.textExpr) {
d.className = "pg-show-text"; const d = document.createElement("div");
d.textContent = def.text; d.className = "pg-show-text";
return d; d.textContent = def.textExpr ? this.host.text(def.textExpr) : def.text!;
} return d;
if (def.file) { }
const img = document.createElement("img"); if (def.frames?.length) return this.animated(def, depth);
img.draggable = false; if (def.layered) return this.layered(def, depth);
img.decoding = "async"; if (def.file) {
const src = this.host.url(def.file); const img = document.createElement("img");
if (src) img.src = src; img.draggable = false;
else console.warn("[pgvn] нет файла", def.file); img.decoding = "async";
if (def.props?.size) { const src = this.host.url(def.file);
img.style.width = `${def.props.size[0]}px`; if (src) img.src = src;
img.style.height = `${def.props.size[1]}px`; else console.warn("[pgvn] нет файла", def.file);
} if (def.props?.size) {
return img; img.style.width = `${def.props.size[0]}px`;
} img.style.height = `${def.props.size[1]}px`;
return document.createElement("div"); }
} return img;
}
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) { return document.createElement("div");
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) }; }
for (const spec of at) {
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {}); /** Frame-by-frame ATL animation: swaps children on timers while attached. */
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {}); private animated(def: ImageDef, depth: number): HTMLElement {
if (spec.props) Object.assign(p, spec.props); const box = document.createElement("div");
} box.style.position = "relative";
normalize(p); const frames = def.frames!;
const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`); let i = 0;
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`); let started = false;
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`; box.append(this.build(frames[0].img, depth + 1));
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`; const tick = () => {
const ax = anchor(p.xanchor); if (box.isConnected) started = true;
const ay = anchor(p.yanchor); else if (started) return;
el.style.transformOrigin = `${ax} ${ay}`; const d = frames[i].d ?? 0;
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1); const last = i === frames.length - 1;
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1); if (!d || (last && !def.repeat)) return;
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : ""); window.setTimeout(() => {
// translate(-%) must use the element box, so negative percentages of px anchors are written as is if (started && !box.isConnected) return;
if (ax.endsWith("px") || ay.endsWith("px")) i = last ? 0 : i + 1;
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`; box.replaceChildren(this.build(frames[i].img, depth + 1));
if (p.alpha !== undefined) el.style.opacity = String(p.alpha); tick();
if (p.filter) el.style.filter = `${p.filter}(1)`; }, d * 1000);
} };
tick();
// --- transitions --------------------------------------------------------------- return box;
}
/** Remember the current picture as the starting point of the next transition. */
commit() { /** layeredimage: stack the layers selected by the image's attributes. */
this.committed = this.master.cloneNode(true) as HTMLElement; private layered(def: ImageDef, depth: number): HTMLElement {
} const box = document.createElement("div");
box.style.position = "relative";
async transition(t: Transition | null | undefined): Promise<void> { const sel = new Set(def.selected ?? []);
const before = this.committed; const groupsSelected = new Set(
this.commit(); def.layered!.filter((l) => l.attr && sel.has(l.attr) && l.group).map((l) => l.group));
if (!t || !before || this.fast) return; const imgs: ImageDef[] = [];
if (t.t === "punch") return this.punch(t); for (const layer of def.layered!) {
const old = before; if (layer.always) imgs.push(layer.always);
old.classList.add("pg-old"); else if (layer.cond) {
this.overlay.append(old); const hit = layer.cond.find(([c]) => c === null || this.host.evalCond(c));
try { if (hit) imgs.push(hit[1]);
if (t.t === "fade") { } else if (layer.attr !== undefined) {
const color = document.createElement("div"); const on = sel.has(layer.attr) || (!!layer.default && !(layer.group && groupsSelected.has(layer.group)));
color.className = "pg-fill"; if (!on || !layer.img || layer.null) continue;
color.style.background = t.color ?? "#000"; if (layer.if_any && !layer.if_any.some((a) => sel.has(a))) continue;
color.style.opacity = "0"; if (layer.if_all && !layer.if_all.every((a) => sel.has(a))) continue;
this.overlay.append(color); if (layer.if_not && layer.if_not.some((a) => sel.has(a))) continue;
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5); imgs.push(layer.img);
old.remove(); }
if (t.hold) await sleep(t.hold); }
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5); imgs.forEach((img, n) => {
color.remove(); const child = this.build(img, depth + 1);
} else if (t.t === "wipe") { if (n > 0) {
const dir = t.dir ?? "right"; child.style.position = "absolute";
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)", child.style.left = "0";
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)"; child.style.top = "0";
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1); }
} else if (t.t === "slide") { box.append(child);
const dir = t.dir ?? "left"; });
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir]; return box;
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 }, }
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
} else { camera(at: AtSpec[]) {
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5); const p: Record<string, any> = {};
} for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
} finally { const zoom = p.zoom ?? 1;
old.remove(); this.master.style.transformOrigin = "50% 50%";
} this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
} this.cameraAt = at;
}
private async punch(t: Transition) {
const axis = t.dir === "h" ? "X" : "Y"; cameraAt: AtSpec[] = [];
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
t.d ?? 0.3); private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
} const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
for (const spec of [...(def.at ?? []), ...at]) {
preload(def: ImageDef | null) { if (spec.effect === "shake") {
if (def?.file) { const dist = spec.dist ?? 20;
const src = this.host.url(def.file); el.animate([0, dist, -dist, dist * 0.6, -dist * 0.6, 0].map((x) => ({ translate: `${x}px 0` })),
if (src) new Image().src = src; { 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] ?? {});
function normalize(p: Record<string, any>) { if (spec.props) Object.assign(p, spec.props);
if (p.align) [p.xalign, p.yalign] = p.align; }
if (p.pos) [p.xpos, p.ypos] = p.pos; normalize(p);
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor; const pos = (v: number, total: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * total}px`);
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign; const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign; el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5]; el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5]; const ax = anchor(p.xanchor);
p.xanchor = asFloat(p.xanchor); const ay = anchor(p.yanchor);
p.yanchor = asFloat(p.yanchor); el.style.transformOrigin = `${ax} ${ay}`;
p.xpos = asFloat(p.xpos); const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
p.ypos = asFloat(p.ypos); 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
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in if (ax.endsWith("px") || ay.endsWith("px"))
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction. el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
function asFloat(v: any): number { if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
return typeof v === "number" ? v : 0; if (p.filter) el.style.filter = `${p.filter}(1)`;
} }
export function sleep(seconds: number) { // --- transitions ---------------------------------------------------------------
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
} /** Remember the current picture as the starting point of the next transition. */
commit() {
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> { this.committed = this.master.cloneNode(true) as HTMLElement;
return new Promise((resolve) => { }
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
a.onfinish = () => resolve(); async transition(t: Transition | null | undefined): Promise<void> {
a.oncancel = () => resolve(); const before = this.committed;
}); this.commit();
} if (!t || !before || this.fast) return;
if (t.t === "punch") return this.punch(t);
const old = before;
old.classList.add("pg-old");
this.overlay.append(old);
try {
if (t.t === "fade") {
const color = document.createElement("div");
color.className = "pg-fill";
color.style.background = t.color ?? "#000";
color.style.opacity = "0";
this.overlay.append(color);
await animate(color, [{ opacity: 0 }, { opacity: 1 }], t.out ?? 0.5);
old.remove();
if (t.hold) await sleep(t.hold);
await animate(color, [{ opacity: 1 }, { opacity: 0 }], t.in ?? 0.5);
color.remove();
} else if (t.t === "wipe") {
const dir = t.dir ?? "right";
const end = { left: "inset(0 100% 0 0)", right: "inset(0 0 0 100%)", up: "inset(0 0 100% 0)",
down: "inset(100% 0 0 0)" }[dir] ?? "inset(0 0 0 100%)";
await animate(old, [{ clipPath: "inset(0 0 0 0)" }, { clipPath: end }], t.d ?? 1);
} else if (t.t === "slide") {
const dir = t.dir ?? "left";
const shift = { left: "-100%, 0", right: "100%, 0", top: "0, -100%", bottom: "0, 100%" }[dir];
await animate(old, [{ transform: "translate(0, 0)", opacity: 1 },
{ transform: `translate(${shift})`, opacity: 0 }], t.d ?? 0.5);
} else {
await animate(old, [{ opacity: 1 }, { opacity: 0 }], t.d ?? 0.5);
}
} finally {
old.remove();
}
}
private async punch(t: Transition) {
const axis = t.dir === "h" ? "X" : "Y";
await animate(this.master, [0, 12, -10, 8, -6, 3, 0].map((v) => ({ transform: `translate${axis}(${v}px)` })),
t.d ?? 0.3);
}
preload(def: ImageDef | null) {
if (def?.file) {
const src = this.host.url(def.file);
if (src) new Image().src = src;
}
}
}
function normalize(p: Record<string, any>) {
if (p.align) [p.xalign, p.yalign] = p.align;
if (p.pos) [p.xpos, p.ypos] = p.pos;
if (p.anchor) [p.xanchor, p.yanchor] = p.anchor;
if (p.xalign !== undefined) p.xpos = p.xanchor = p.xalign;
if (p.yalign !== undefined) p.ypos = p.yanchor = p.yalign;
if (p.xcenter !== undefined) [p.xpos, p.xanchor] = [p.xcenter, 0.5];
if (p.ycenter !== undefined) [p.ypos, p.yanchor] = [p.ycenter, 0.5];
p.xanchor = asFloat(p.xanchor);
p.yanchor = asFloat(p.yanchor);
p.xpos = asFloat(p.xpos);
p.ypos = asFloat(p.ypos);
}
// Ren'Py: float means fraction, int means pixels — but 0 and 1 as ints are common in
// ATL ("xalign 1") and still mean fractions there; renderer treats |v|<=1 as fraction.
function asFloat(v: any): number {
return typeof v === "number" ? v : 0;
}
export function sleep(seconds: number) {
return new Promise<void>((r) => setTimeout(r, seconds * 1000));
}
function animate(el: HTMLElement, frames: Keyframe[], seconds: number): Promise<void> {
return new Promise((resolve) => {
const a = el.animate(frames, { duration: seconds * 1000, fill: "forwards", easing: "ease-in-out" });
a.onfinish = () => resolve();
a.oncancel = () => resolve();
});
}
+143 -108
View File
@@ -1,108 +1,143 @@
// PGVN v1 — the format produced by the backend converter (see docs/ARCHITECTURE.md). // PGVN v1 — the format produced by the backend converter (see docs/ARCHITECTURE.md).
export type Expr = { k: string; [key: string]: any }; export type Expr = { k: string; [key: string]: any };
export type PyStmt = { op: string; [key: string]: any }; export type PyStmt = { op: string; [key: string]: any };
export interface Transition { export interface Transition {
t: "dissolve" | "fade" | "move" | "punch" | "wipe" | "slide" | "ref"; t: "dissolve" | "fade" | "move" | "punch" | "wipe" | "slide" | "ref";
d?: number; d?: number;
out?: number; out?: number;
hold?: number; hold?: number;
in?: number; in?: number;
color?: string; color?: string;
dir?: string; dir?: string;
name?: string; name?: string;
} }
export interface ImageDef { export interface ImageDef {
file?: string; file?: string;
color?: string; color?: string;
ref?: string; ref?: string;
movie?: string; movie?: string;
expr?: Expr; expr?: Expr;
switch?: [Expr, ImageDef][]; switch?: [Expr, ImageDef][];
composite?: { pos: [number, number]; img: ImageDef }[]; composite?: { pos: [number, number]; img: ImageDef }[];
size?: [number, number] | null; size?: [number, number] | null;
text?: string; text?: string;
props?: Record<string, any>; textExpr?: Expr;
} props?: Record<string, any>;
frames?: { img: ImageDef; d?: number }[];
export interface AtSpec { repeat?: boolean;
pos?: string; layered?: Layer[];
name?: string; at?: AtSpec[];
props?: Record<string, any>; selected?: string[]; // attributes chosen for a layered image (set by the engine)
} }
export interface CharacterDef { export interface Layer {
name?: string | null; always?: ImageDef;
nameExpr?: Expr; attr?: string;
color?: string; group?: string | null;
whatColor?: string; default?: boolean;
whatPrefix?: string; null?: boolean;
whatSuffix?: string; img?: ImageDef;
whoPrefix?: string; if_any?: string[];
whoSuffix?: string; if_all?: string[];
image?: string; if_not?: string[];
nvl?: boolean; cond?: [Expr | null, ImageDef][];
whatItalic?: boolean; autoGroup?: string;
whatBold?: boolean; }
}
export interface AtSpec {
export type Node = { op: string; [key: string]: any }; pos?: string;
name?: string;
export interface Label { props?: Record<string, any>;
name: string; effect?: "shake";
body: Node[]; d?: number;
} dist?: number;
}
export interface ScriptModule {
file: string; export interface CharacterDef {
labels: Label[]; name?: string | null;
} nameExpr?: Expr;
color?: string;
export interface GameData { whatColor?: string;
format: string; whatPrefix?: string;
title: string | null; whatSuffix?: string;
resolution: [number, number]; whoPrefix?: string;
entry: string; whoSuffix?: string;
scripts: string[]; image?: string;
characters: Record<string, CharacterDef>; nvl?: boolean;
images: Record<string, ImageDef>; whatItalic?: boolean;
transforms: Record<string, Record<string, any>>; whatBold?: boolean;
transitions: Record<string, Transition | null>; }
audio: Record<string, string>;
files: Record<string, string>; export type Node = { op: string; [key: string]: any };
defaults: [string, Expr][];
init: PyStmt[]; export interface Label {
ui: Record<string, any>; name: string;
} body: Node[];
params?: [string, Expr | null][];
export interface Manifest { }
format: string;
files: Record<string, { hash: string; size: number }>; export interface Anchor {
} label: string;
path: number[];
export interface Frame { params?: [string, Expr | null][] | null;
label: string; }
path: number[];
} export interface ScriptModule {
file: string;
export interface ShownImage { labels: Label[];
tag: string; anchors?: Record<string, Anchor>;
name: string[]; }
at: AtSpec[];
zorder: number; export interface GameData {
layer?: string; format: string;
} title: string | null;
resolution: [number, number];
export interface SaveData { entry: string;
v: 1; scripts: string[];
cur: Frame; characters: Record<string, CharacterDef>;
stack: Frame[]; images: Record<string, ImageDef>;
store: any; transforms: Record<string, Record<string, any>>;
scene: ShownImage[]; transitions: Record<string, Transition | null>;
music: { file: string; loop: boolean } | null; audio: Record<string, string>;
window: boolean; channels?: Record<string, { mixer: string; loop: boolean }>;
menuSeen?: string[]; files: Record<string, string>;
text?: string; defaults: [string, Expr][];
} init: PyStmt[];
ui: Record<string, any>;
}
export interface Manifest {
format: string;
files: Record<string, { hash: string; size: number }>;
}
export interface Frame {
label: string;
path: number[];
// values of label parameters shadowed by a call, restored on return
saved?: Record<string, { had: boolean; value: any }>;
}
export interface ShownImage {
tag: string;
name: string[];
at: AtSpec[];
zorder: number;
layer?: string;
}
export interface SaveData {
v: 1;
cur: Frame;
stack: Frame[];
store: any;
scene: ShownImage[];
music: { file: string; loop: boolean } | null;
window: boolean;
camera?: AtSpec[];
menuSeen?: string[];
text?: string;
}
+153 -153
View File
@@ -1,153 +1,153 @@
:root { :root {
--bg: #0f1117; --bg: #0f1117;
--panel: #171a23; --panel: #171a23;
--panel-2: #1f2330; --panel-2: #1f2330;
--border: #2a2f3e; --border: #2a2f3e;
--text: #e6e8ef; --text: #e6e8ef;
--muted: #8b90a0; --muted: #8b90a0;
--accent: #ff9f43; --accent: #ff9f43;
--danger: #ef5a5a; --danger: #ef5a5a;
--ok: #4cc38a; --ok: #4cc38a;
color-scheme: dark; color-scheme: dark;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; } html, body, #root { height: 100%; margin: 0; }
body { body {
background: var(--bg); background: var(--bg);
color: var(--text); color: var(--text);
font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif; font: 15px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif;
} }
a { color: inherit; text-decoration: none; } a { color: inherit; text-decoration: none; }
h1 { font-size: 1.6rem; margin: 0 0 0.75rem; } h1 { font-size: 1.6rem; margin: 0 0 0.75rem; }
h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; } h2 { font-size: 1.2rem; margin: 1.5rem 0 0.75rem; }
input, select, textarea, button, .button { input, select, textarea, button, .button {
font: inherit; font: inherit;
color: var(--text); color: var(--text);
background: var(--panel-2); background: var(--panel-2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
padding: 0.45rem 0.7rem; padding: 0.45rem 0.7rem;
} }
input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); } input:focus, select:focus, textarea:focus { outline: 1px solid var(--accent); }
button, .button { cursor: pointer; white-space: nowrap; } button, .button { cursor: pointer; white-space: nowrap; }
button:hover:not(:disabled), .button:hover { border-color: var(--accent); } button:hover:not(:disabled), .button:hover { border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: default; } button:disabled { opacity: 0.5; cursor: default; }
button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; } button.primary { background: var(--accent); border-color: var(--accent); color: #1a1205; font-weight: 600; }
button.danger { color: var(--danger); } button.danger { color: var(--danger); }
button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); } button.link { background: none; border: 0; padding: 0 0.3rem; color: var(--muted); }
button.link:hover { color: var(--text); } button.link:hover { color: var(--text); }
.muted { color: var(--muted); } .muted { color: var(--muted); }
.small { font-size: 0.85em; } .small { font-size: 0.85em; }
.error { color: var(--danger); margin: 0.5rem 0; } .error { color: var(--danger); margin: 0.5rem 0; }
.ok { color: var(--ok); } .ok { color: var(--ok); }
.center { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 1rem; } .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; } .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; } .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 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; } .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 { flex-direction: row !important; flex-wrap: wrap; align-items: center; margin: 1rem 0; }
.inline-form input { flex: 1; min-width: 200px; } .inline-form input { flex: 1; min-width: 200px; }
.layout { min-height: 100%; display: flex; flex-direction: column; } .layout { min-height: 100%; display: flex; flex-direction: column; }
.topbar { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1.5rem; gap: 1.5rem;
padding: 0.7rem 1.5rem; padding: 0.7rem 1.5rem;
background: var(--panel); background: var(--panel);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
flex-wrap: wrap; flex-wrap: wrap;
} }
.brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; } .brand { font-weight: 700; color: var(--accent); font-size: 1.15rem; }
.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; } .topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }
.topbar nav a { color: var(--muted); } .topbar nav a { color: var(--muted); }
.topbar nav a.active, .topbar nav a:hover { color: var(--text); } .topbar nav a.active, .topbar nav a:hover { color: var(--text); }
.user { display: flex; align-items: center; gap: 0.3rem; } .user { display: flex; align-items: center; gap: 0.3rem; }
.page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; } .page { padding: 1.5rem; max-width: 1400px; margin: 0 auto; width: 100%; }
.page.narrow { max-width: 560px; } .page.narrow { max-width: 560px; }
.search input { width: 100%; font-size: 1.05rem; padding: 0.7rem 1rem; margin-bottom: 1.25rem; } .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; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; }
.game-card { .game-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
transition: transform 0.15s, border-color 0.15s; transition: transform 0.15s, border-color 0.15s;
} }
.game-card:hover { transform: translateY(-3px); border-color: var(--accent); } .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; } .game-card-body { padding: 0.6rem 0.8rem; display: flex; flex-direction: column; gap: 0.35rem; }
.cover { .cover {
aspect-ratio: 16 / 10; aspect-ratio: 16 / 10;
background: linear-gradient(135deg, #262b3b, #1a1d29); background: linear-gradient(135deg, #262b3b, #1a1d29);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 2.5rem; font-size: 2.5rem;
color: var(--muted); color: var(--muted);
overflow: hidden; overflow: hidden;
} }
.cover img { width: 100%; height: 100%; object-fit: cover; } .cover img { width: 100%; height: 100%; object-fit: cover; }
.cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; } .cover.big { width: 340px; max-width: 100%; border-radius: 10px; flex-shrink: 0; }
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; } .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); } .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-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; } .game-info { flex: 1; min-width: 260px; display: flex; flex-direction: column; gap: 0.6rem; }
.description { white-space: pre-wrap; margin: 0; } .description { white-space: pre-wrap; margin: 0; }
.actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; } .actions { display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; }
.editor { display: flex; flex-direction: column; gap: 0.6rem; } .editor { display: flex; flex-direction: column; gap: 0.6rem; }
.versions { 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 { 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; } .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 { 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-ready { color: var(--ok); }
.status-failed { color: var(--danger); } .status-failed { color: var(--danger); }
.status-queued, .status-converting, .status-uploading { color: var(--accent); } .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 { 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-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 table { border-collapse: collapse; margin-top: 0.4rem; }
.report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; } .report td { padding: 0.15rem 0.6rem 0.15rem 0; vertical-align: top; }
.report summary { cursor: pointer; margin: 0.3rem 0; } .report summary { cursor: pointer; margin: 0.3rem 0; }
.progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; } .progress { height: 8px; background: var(--panel-2); border-radius: 99px; overflow: hidden; }
.progress > div { height: 100%; background: var(--accent); transition: width 0.2s; } .progress > div { height: 100%; background: var(--accent); transition: width 0.2s; }
.table-wrap { overflow-x: auto; } .table-wrap { overflow-x: auto; }
.table { width: 100%; border-collapse: collapse; margin-top: 1rem; } .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, .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 th { color: var(--muted); font-weight: 500; }
.table tr.inactive td { opacity: 0.55; } .table tr.inactive td { opacity: 0.55; }
.center-cell { text-align: center !important; } .center-cell { text-align: center !important; }
.roles th:not(:first-child) { text-align: center; } .roles th:not(:first-child) { text-align: center; }
.issued { border-color: var(--accent); margin-bottom: 1rem; } .issued { border-color: var(--accent); margin-bottom: 1rem; }
.password-box { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; } .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; } .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 { position: fixed; inset: 0; background: #000; }
.play-host { position: absolute; inset: 0; } .play-host { position: absolute; inset: 0; }
.play-status { .play-status {
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--muted); color: var(--muted);
pointer-events: none; pointer-events: none;
} }
.play-status button { pointer-events: auto; } .play-status button { pointer-events: auto; }
@media (max-width: 640px) { @media (max-width: 640px) {
.page { padding: 1rem; } .page { padding: 1rem; }
.topbar { padding: 0.6rem 1rem; gap: 0.8rem; } .topbar { padding: 0.6rem 1rem; gap: 0.8rem; }
.grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); } .grid { grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); }
} }