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:
1 parent
35033fb86c
commit
a97b46079e
18 files changed
+3456
-2695
No files matched your search
@@ -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` и картинки,
|
||||||
|
которые игра вычисляет своими функциями. Такие места перечислены в отчёте
|
||||||
|
конвертации у версии. Игры-«песочницы», где навигация построена на экранах,
|
||||||
|
пока не играбельны.
|
||||||
@@ -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)
|
||||||
@@ -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
@@ -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__}")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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]}`;
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+366
-287
@@ -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
@@ -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
@@ -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)); }
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user