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
+845
-84
No files matched your search
@@ -40,6 +40,25 @@ games.example.lan {
|
||||
при первом входе он задаст свой. Права настраиваются через роли
|
||||
(«Роли»): `admin`, `moderator`, `player` плюс свои.
|
||||
|
||||
## Импорт уже распакованных игр
|
||||
|
||||
Если игры уже лежат на VM распакованными, архивировать их не нужно.
|
||||
Подключите папку к контейнеру (только чтение) и запустите импорт:
|
||||
|
||||
```bash
|
||||
docker compose run --rm -v /srv/games:/games:ro backend \
|
||||
python -m app.import_dir "/games/Some-Game" --title "Some Game"
|
||||
```
|
||||
|
||||
Папка не изменяется, сконвертированная копия попадает в библиотеку как
|
||||
обычная загрузка. Проверить, насколько хорошо конвертер понимает игру,
|
||||
не трогая медиа (минуты вместо часов), можно сухим прогоном:
|
||||
|
||||
```bash
|
||||
docker compose run --rm -v /srv/games:/games:ro backend \
|
||||
python -W ignore -m app.converter.analyze "/games/Some-Game"
|
||||
```
|
||||
|
||||
## Данные и бэкап
|
||||
|
||||
* `./data/blobs` — сконвертированные игры (неизменяемые файлы по sha256);
|
||||
@@ -69,10 +88,15 @@ python -m tests.make_sample sample.zip # тестовая игра (из ba
|
||||
`while`, `jump/call/return` (включая `expression`), `$`/`python:`/`init python:`
|
||||
(подмножество Python: переменные, выражения, списки/словари, циклы),
|
||||
`define/default`, `Character(...)`, `image` (файлы, цвета, `Transform`, `im.*`,
|
||||
`Composite`, `ConditionSwitch`, простые ATL), `transform` со статическими
|
||||
свойствами, `play/queue/stop/voice`, `pause`, `.rpa`, `.rpyc` (через unrpyc),
|
||||
`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, `layeredimage`, функции/классы в `python` — такие места
|
||||
перечислены в отчёте конвертации у версии.
|
||||
Пока **не** поддерживается: `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:
|
||||
"""Convert an uploaded archive, or an already unpacked game directory (left untouched)."""
|
||||
started = time.monotonic()
|
||||
if work.exists():
|
||||
shutil.rmtree(work)
|
||||
@@ -44,9 +45,14 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
out_dir.mkdir(parents=True)
|
||||
data = ScriptData()
|
||||
|
||||
progress("Распаковка архива")
|
||||
extract(archive, filename, src_root)
|
||||
game_dir = find_game_dir(src_root)
|
||||
if archive.is_dir():
|
||||
progress("Копирование файлов игры")
|
||||
game_dir = src_root / "game"
|
||||
shutil.copytree(find_game_dir(archive), game_dir)
|
||||
else:
|
||||
progress("Распаковка архива")
|
||||
extract(archive, filename, src_root)
|
||||
game_dir = find_game_dir(src_root)
|
||||
|
||||
rpas = sorted(game_dir.rglob("*.rpa"))
|
||||
for i, rpa in enumerate(rpas, 1):
|
||||
@@ -115,7 +121,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
scripts: list[str] = []
|
||||
for rel, labels in data.modules.items():
|
||||
logical = "script/" + str(PurePosixPath(rel).with_suffix(".json"))
|
||||
payload = json.dumps({"file": rel, "labels": labels}, ensure_ascii=False,
|
||||
payload = json.dumps({"file": rel, "labels": labels, "anchors": anchors(labels)},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":")).encode()
|
||||
blob_id, size = put_bytes(payload, ".json")
|
||||
files[logical] = {"hash": blob_id, "size": size}
|
||||
@@ -138,6 +145,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
"transforms": data.transforms,
|
||||
"transitions": data.transitions,
|
||||
"audio": audio,
|
||||
"channels": data.channels,
|
||||
"files": aliases,
|
||||
"defaults": data.defaults,
|
||||
"init": data.init_code,
|
||||
@@ -169,12 +177,18 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
{"message": msg, "count": n, "example": data.warning_examples.get(msg)}
|
||||
for msg, n in data.warnings.most_common()
|
||||
],
|
||||
"sourceSize": archive.stat().st_size,
|
||||
"sourceSize": _size(archive),
|
||||
"outputSize": total,
|
||||
}
|
||||
return Result({"format": FORMAT, "files": files}, report, title, cover, total)
|
||||
|
||||
|
||||
def _size(path: Path) -> int:
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
return sum(p.stat().st_size for p in path.rglob("*") if p.is_file())
|
||||
|
||||
|
||||
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
||||
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
||||
return len(parts) > 1 and parts[0] in ("cache", "saves", "tl", "renpy")
|
||||
@@ -203,9 +217,47 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st
|
||||
if "composite" in img:
|
||||
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
|
||||
for layer in img["composite"]]
|
||||
if "frames" in img:
|
||||
img["frames"] = [{**f, "img": _resolve_image(f["img"], aliases, missing)}
|
||||
for f in img["frames"]]
|
||||
if "layered" in img:
|
||||
layers = []
|
||||
for layer in img["layered"]:
|
||||
layer = dict(layer)
|
||||
for key in ("always", "img"):
|
||||
if key in layer:
|
||||
layer[key] = _resolve_image(layer[key], aliases, missing)
|
||||
if "cond" in layer:
|
||||
layer["cond"] = [[c, _resolve_image(sub, aliases, missing)]
|
||||
for c, sub in layer["cond"]]
|
||||
layers.append(layer)
|
||||
img["layered"] = layers
|
||||
return img
|
||||
|
||||
|
||||
def anchors(labels: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""Positions of `label` markers (nested labels, named menus) inside label bodies."""
|
||||
found: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def walk(nodes: list[dict[str, Any]], top: str, path: list[int]) -> None:
|
||||
for i, n in enumerate(nodes):
|
||||
here = path + [i]
|
||||
if n["op"] == "label":
|
||||
found[n["name"]] = {"label": top, "path": here, "params": n.get("params")}
|
||||
if n["op"] == "if":
|
||||
for b, branch in enumerate(n["branches"]):
|
||||
walk(branch["body"], top, here + [b])
|
||||
elif n["op"] == "menu":
|
||||
for b, item in enumerate(n["items"]):
|
||||
walk(item["body"], top, here + [b])
|
||||
elif n["op"] == "while":
|
||||
walk(n["body"], top, here + [0])
|
||||
|
||||
for label in labels:
|
||||
walk(label["body"], label["name"], [])
|
||||
return found
|
||||
|
||||
|
||||
def _auto_images(data: ScriptData, aliases: dict[str, str]) -> None:
|
||||
"""Ren'Py defines an image for every file under images/ (name = lowercase basename)."""
|
||||
for rel, logical in aliases.items():
|
||||
|
||||
@@ -117,8 +117,12 @@ def expr(node: ast.AST) -> Expr:
|
||||
elif isinstance(v, ast.FormattedValue):
|
||||
parts.append(expr(v.value))
|
||||
return {"k": "fmt", "v": parts}
|
||||
case ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
|
||||
is_async=0)]):
|
||||
case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
|
||||
is_async=0)])
|
||||
| ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
|
||||
ifs=ifs, is_async=0)])
|
||||
| ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
|
||||
is_async=0)])):
|
||||
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
|
||||
"if": [expr(i) for i in ifs]}
|
||||
case ast.Lambda():
|
||||
|
||||
@@ -7,6 +7,7 @@ unpickler that only allows plain containers — archives are untrusted input.
|
||||
import io
|
||||
import pickle
|
||||
import zlib
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -65,13 +66,15 @@ def read_index(path: Path) -> dict[str, list[tuple[int, int, bytes]]]:
|
||||
return index
|
||||
|
||||
|
||||
def extract_rpa(path: Path, dest: Path) -> int:
|
||||
"""Extract all files of an archive into dest. Returns the number of files."""
|
||||
def extract_rpa(path: Path, dest: Path, only: Callable[[str], bool] | None = None) -> int:
|
||||
"""Extract files of an archive into dest (all, or those `only` accepts)."""
|
||||
index = read_index(path)
|
||||
root = dest.resolve()
|
||||
count = 0
|
||||
with path.open("rb") as f:
|
||||
for name, entries in index.items():
|
||||
if only is not None and not only(name):
|
||||
continue
|
||||
target = (dest / name).resolve()
|
||||
if not target.is_relative_to(root):
|
||||
continue
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Ren'Py script (.rpy) → PGVN IR."""
|
||||
|
||||
import ast
|
||||
import re
|
||||
import warnings
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -43,7 +45,7 @@ POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xce
|
||||
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
|
||||
"align", "pos", "anchor", "xysize", "size"}
|
||||
CHANNELS = {"music", "sound", "voice", "audio", "movie"}
|
||||
SKIP_BLOCKS = {"screen", "style", "translate", "testcase", "layeredimage", "camera"}
|
||||
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
|
||||
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
|
||||
|
||||
|
||||
@@ -57,6 +59,7 @@ class ScriptData:
|
||||
defaults: list[list[Any]] = field(default_factory=list)
|
||||
init_code: list[Stmt] = field(default_factory=list) # defines + init python, in order
|
||||
gui: dict[str, Any] = field(default_factory=dict)
|
||||
channels: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
|
||||
warnings: Counter = field(default_factory=Counter)
|
||||
@@ -73,6 +76,11 @@ class RpyParser:
|
||||
def __init__(self, data: ScriptData, filename: str):
|
||||
self.d = data
|
||||
self.filename = filename
|
||||
self.global_label = ""
|
||||
|
||||
def label_ref(self, name: str) -> str:
|
||||
"""Resolve `.local` label names against the enclosing global label."""
|
||||
return self.global_label + name if name.startswith(".") else name
|
||||
|
||||
# --- helpers -------------------------------------------------------------
|
||||
|
||||
@@ -92,7 +100,7 @@ class RpyParser:
|
||||
def python(self, source: str, line: Line) -> list[Stmt]:
|
||||
"""Translate python code statement by statement, skipping unsupported ones."""
|
||||
try:
|
||||
tree = ast.parse(_dedent(source))
|
||||
tree = _parse_py(source)
|
||||
except SyntaxError as e:
|
||||
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
|
||||
return []
|
||||
@@ -114,13 +122,12 @@ class RpyParser:
|
||||
c = self.cursor(line)
|
||||
first = c.peek_word()
|
||||
if first == "label":
|
||||
c.keyword("label")
|
||||
name = c.label_name()
|
||||
if not name:
|
||||
raise c.error("ожидалось имя метки")
|
||||
if c.char("("):
|
||||
self.d.warn("Параметры меток не поддерживаются", self.where(line))
|
||||
current = {"name": name, "body": self.block(line.block)}
|
||||
name, params = self.label_header(line)
|
||||
if not name.startswith("."):
|
||||
self.global_label = name
|
||||
current = {"name": self.label_ref(name), "body": self.block(line.block)}
|
||||
if params:
|
||||
current["params"] = params
|
||||
labels.append(current)
|
||||
continue
|
||||
nodes = self.statement(line, top_level=True)
|
||||
@@ -143,7 +150,15 @@ class RpyParser:
|
||||
out.append({"op": "if", "branches": branches})
|
||||
continue
|
||||
if word == "label":
|
||||
self.d.warn("Вложенные метки не поддерживаются", self.where(line))
|
||||
# a label inside a block is an entry point in the middle of the flow:
|
||||
# keep a marker the player can jump to and inline the label's block
|
||||
name, params = self.label_header(line)
|
||||
if not name.startswith("."):
|
||||
self.global_label = name
|
||||
marker: Node = {"op": "label", "name": self.label_ref(name)}
|
||||
if params:
|
||||
marker["params"] = params
|
||||
out.append(marker)
|
||||
out.extend(self.block(line.block))
|
||||
i += 1
|
||||
continue
|
||||
@@ -151,6 +166,37 @@ class RpyParser:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
def label_header(self, line: Line) -> tuple[str, list[list[Any]] | None]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("label")
|
||||
name = c.label_name()
|
||||
if not name:
|
||||
raise c.error("ожидалось имя метки")
|
||||
params = None
|
||||
if c.peek("("):
|
||||
sig = c.until(set(), ":")
|
||||
try:
|
||||
fn = _parse_py(f"def _f{sig}: pass").body[0]
|
||||
args = fn.args
|
||||
defaults = [None] * (len(args.args) - len(args.defaults)) + list(args.defaults)
|
||||
params = [[a.arg, pyexpr.expr(d) if d is not None else None]
|
||||
for a, d in zip(args.args, defaults)]
|
||||
except (SyntaxError, Unsupported):
|
||||
self.d.warn("Не разобраны параметры метки", self.where(line))
|
||||
return name, params
|
||||
|
||||
def call_args(self, c: Cursor, line: Line) -> dict[str, Any] | None:
|
||||
if not c.peek("("):
|
||||
return None
|
||||
src = c.until({"from"})
|
||||
try:
|
||||
call = _parse_py(f"_f{src}", mode="eval").body
|
||||
return {"a": [pyexpr.expr(a) for a in call.args],
|
||||
"kw": {k.arg: pyexpr.expr(k.value) for k in call.keywords}}
|
||||
except (SyntaxError, Unsupported, AttributeError):
|
||||
self.d.warn("Не разобраны аргументы call", self.where(line))
|
||||
return None
|
||||
|
||||
def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]:
|
||||
branches: list[Node] = []
|
||||
while i < len(lines):
|
||||
@@ -282,11 +328,15 @@ class RpyParser:
|
||||
|
||||
def init_python(self, source: str, line: Line) -> None:
|
||||
try:
|
||||
tree = ast.parse(_dedent(source))
|
||||
tree = _parse_py(source)
|
||||
except SyntaxError as e:
|
||||
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
|
||||
return
|
||||
for node in tree.body:
|
||||
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||
and ast.unparse(node.value.func) == "renpy.music.register_channel"):
|
||||
self.register_channel(node.value)
|
||||
continue
|
||||
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
||||
tgt = node.targets[0]
|
||||
name = _assign_name(tgt)
|
||||
@@ -306,6 +356,21 @@ class RpyParser:
|
||||
except Unsupported as e:
|
||||
self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
|
||||
|
||||
def register_channel(self, call: ast.Call) -> None:
|
||||
args = list(call.args) + [None] * 3
|
||||
kw = {k.arg: k.value for k in call.keywords}
|
||||
|
||||
def lit(node, default=None):
|
||||
try:
|
||||
return ast.literal_eval(node) if node is not None else default
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
name = lit(args[0])
|
||||
if isinstance(name, str):
|
||||
self.d.channels[name] = {"mixer": lit(kw.get("mixer", args[1]), "sfx"),
|
||||
"loop": bool(lit(kw.get("loop", args[2]), False))}
|
||||
|
||||
# define / default / image / transform -------------------------------------------
|
||||
|
||||
def st_define(self, line: Line) -> list[Node]:
|
||||
@@ -499,25 +564,182 @@ class RpyParser:
|
||||
def atl_image(self, line: Line) -> dict[str, Any] | None:
|
||||
frames: list[dict[str, Any]] = []
|
||||
props: dict[str, Any] = {}
|
||||
for child in line.block:
|
||||
repeat = self.atl_frames(line.block, frames, props)
|
||||
if not frames:
|
||||
self.d.warn("ATL-изображение без кадров", self.where(line))
|
||||
return None
|
||||
if len(frames) == 1:
|
||||
img = dict(frames[0]["img"])
|
||||
else:
|
||||
img = {"frames": frames, "repeat": repeat}
|
||||
if props:
|
||||
img["props"] = {**img.get("props", {}), **props}
|
||||
return img
|
||||
|
||||
def atl_frames(self, lines: list[Line], frames: list[dict[str, Any]],
|
||||
props: dict[str, Any]) -> bool:
|
||||
"""Collect displayable frames of an ATL block. Returns whether it repeats."""
|
||||
repeat = False
|
||||
for child in lines:
|
||||
c = self.cursor(child)
|
||||
word = c.peek_word()
|
||||
if word in ("contains", "block", "parallel", "choice"):
|
||||
if not frames or word == "block":
|
||||
repeat = self.atl_frames(child.block, frames, props) or repeat
|
||||
continue
|
||||
if word == "repeat":
|
||||
repeat = True
|
||||
continue
|
||||
if word in ("pause",) or _NUMBER_LINE.match(child.text):
|
||||
c.keyword("pause")
|
||||
d = _num(c.number())
|
||||
if frames and d is not None:
|
||||
frames[-1]["d"] = frames[-1].get("d", 0) + d
|
||||
continue
|
||||
if word in ("function", "on", "event", "time", "transform"):
|
||||
continue
|
||||
s = c.string()
|
||||
if s is not None:
|
||||
img = self.displayable_expr(const(s), child)
|
||||
if img:
|
||||
frames.append(img)
|
||||
frames.append({"img": img})
|
||||
continue
|
||||
m = _CALL_LINE.match(child.text)
|
||||
if m and m.group(1) in _DISPLAYABLE_CALLS:
|
||||
e = self.expr(child.text.split(" with ")[0], child)
|
||||
img = self.displayable_expr(e, child) if e else None
|
||||
if img:
|
||||
frames.append({"img": img})
|
||||
continue
|
||||
if m and not frames and not _atl_props(child.text):
|
||||
# e.g. get_face_path("base"): an image computed by game code
|
||||
e = self.expr(child.text, child)
|
||||
if e is not None:
|
||||
self.d.warn("Изображение задано выражением", self.where(child))
|
||||
frames.append({"img": {"expr": e}})
|
||||
continue
|
||||
if not frames or not frames[-1].get("d"):
|
||||
props.update(_atl_props(child.text))
|
||||
return repeat
|
||||
|
||||
def st_layeredimage(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("layeredimage")
|
||||
parts: list[str] = []
|
||||
while (p := c.image_part()) is not None:
|
||||
parts.append(p)
|
||||
name = " ".join(parts)
|
||||
layers: list[dict[str, Any]] = []
|
||||
at: list[dict[str, Any]] = []
|
||||
for child in line.block:
|
||||
cc = self.cursor(child)
|
||||
kw = cc.word()
|
||||
if kw == "always":
|
||||
img = self.layer_image(cc, child, None)
|
||||
if img:
|
||||
layers.append({"always": img})
|
||||
elif kw == "attribute":
|
||||
layers.append(self.layer_attribute(cc, child, name, None))
|
||||
elif kw == "group":
|
||||
group = cc.word() or ""
|
||||
auto = False
|
||||
while not cc.eol():
|
||||
w = cc.word()
|
||||
if w == "auto":
|
||||
auto = True
|
||||
elif w in ("multiple", "prefix", "variant"):
|
||||
cc.word() if w in ("prefix", "variant") else None
|
||||
elif w is None:
|
||||
break
|
||||
for sub in child.block:
|
||||
sc = self.cursor(sub)
|
||||
if sc.keyword("attribute"):
|
||||
layers.append(self.layer_attribute(sc, sub, name, group))
|
||||
if auto:
|
||||
layers.append({"autoGroup": group})
|
||||
elif kw == "if":
|
||||
cond = self.expr(cc.rest().removesuffix(":"), child, const(False))
|
||||
img = self.layer_image(Cursor(""), child, None)
|
||||
if img:
|
||||
layers.append({"cond": [[cond, img]]})
|
||||
elif kw in ("elif", "else"):
|
||||
img = self.layer_image(Cursor(""), child, None)
|
||||
if img and layers and "cond" in layers[-1]:
|
||||
cond = None
|
||||
if kw == "elif":
|
||||
cond = self.expr(cc.rest().removesuffix(":"), child, const(False))
|
||||
layers[-1]["cond"].append([cond, img])
|
||||
elif kw == "at":
|
||||
for item in _split_top(cc.rest()):
|
||||
at.append(self.transform_ref(item, child))
|
||||
self.d.images[name] = {"layered": layers, "at": at}
|
||||
self.d.stats["layeredimage"] += 1
|
||||
return []
|
||||
|
||||
def layer_attribute(self, c: Cursor, line: Line, image: str, group: str | None
|
||||
) -> dict[str, Any]:
|
||||
attr = c.image_part() or ""
|
||||
layer: dict[str, Any] = {"attr": attr, "group": group}
|
||||
img = None
|
||||
while not c.eol():
|
||||
if c.peek('"') or c.peek("'"):
|
||||
img = self.displayable_expr(const(c.string()), line)
|
||||
continue
|
||||
w = c.word()
|
||||
if w == "default":
|
||||
layer["default"] = True
|
||||
elif w == "null":
|
||||
layer["null"] = True
|
||||
elif w in ("if_any", "if_all", "if_not"):
|
||||
src = c.until({"if_any", "if_all", "if_not", "default", "null", "variant", "at"},
|
||||
":")
|
||||
try:
|
||||
val = pyexpr.literal(src)
|
||||
layer[w] = [val] if isinstance(val, str) else list(val)
|
||||
except Unsupported:
|
||||
pass
|
||||
elif w in ("variant", "at"):
|
||||
c.until({"if_any", "if_all", "if_not", "default", "null"}, ":")
|
||||
elif w is None:
|
||||
break
|
||||
if img is None and line.block:
|
||||
img = self.layer_image(Cursor(""), line, None)
|
||||
if img is None and not layer.get("null"):
|
||||
# Ren'Py's default naming: <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))
|
||||
if not frames:
|
||||
self.d.warn("ATL-изображение без кадров", self.where(line))
|
||||
return None
|
||||
if len(frames) > 1:
|
||||
self.d.warn("Анимированные ATL-изображения упрощены до первого кадра",
|
||||
self.where(line))
|
||||
img = dict(frames[0])
|
||||
if props:
|
||||
img["props"] = {**img.get("props", {}), **props}
|
||||
return img
|
||||
node["at"].append({"props": props})
|
||||
return [node]
|
||||
|
||||
def st_transform(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
@@ -588,6 +810,10 @@ class RpyParser:
|
||||
fname = _dotted_name(e["f"])
|
||||
if fname in ("Position", "Transform"):
|
||||
return {"props": _static_props(e["kw"])}
|
||||
if fname in ("Shake", "SnoopyShake"):
|
||||
args = [_const(a) for a in e["a"]]
|
||||
return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5,
|
||||
"dist": _const(e["kw"].get("dist")) or 20}
|
||||
if fname in self.d.transforms:
|
||||
return {"props": self.d.transforms[fname], "name": fname}
|
||||
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
|
||||
@@ -615,8 +841,12 @@ class RpyParser:
|
||||
self.d.warn("Не поддерживается: screen", self.where(line))
|
||||
return []
|
||||
if c.keyword("text"):
|
||||
text = c.string() or ""
|
||||
node: Node = {"op": "show", "img": {"text": text}}
|
||||
text = c.string()
|
||||
if text is not None:
|
||||
node: Node = {"op": "show", "img": {"text": text}}
|
||||
else:
|
||||
node = {"op": "show", "img": {"textExpr": self.expr(c.until(_SHOW_KEYWORDS), line,
|
||||
const(""))}}
|
||||
self.show_clauses(c, line, node)
|
||||
return [node]
|
||||
node = {"op": "show", "img": self.image_spec(c, line)}
|
||||
@@ -668,6 +898,8 @@ class RpyParser:
|
||||
fin = nums[2] if len(nums) > 2 else (nums[1] if len(nums) > 1 else out)
|
||||
return {"t": "fade", "out": out, "hold": hold, "in": fin,
|
||||
"color": kw.get("color") or "#000000"}
|
||||
if fname in ("Shake", "SnoopyShake"):
|
||||
return {"t": "punch", "dir": "h", "d": nums[0] if nums else 0.5}
|
||||
if fname in ("MoveTransition", "Move"):
|
||||
return {"t": "move", "d": nums[0] if nums else 0.5}
|
||||
if fname in TRANSITIONS:
|
||||
@@ -690,8 +922,6 @@ class RpyParser:
|
||||
c = self.cursor(line)
|
||||
c.keyword(op)
|
||||
ch = c.word() or "music"
|
||||
if ch not in CHANNELS:
|
||||
self.d.warn(f"Нестандартный аудиоканал {ch}", self.where(line))
|
||||
node: Node = {"op": op, "ch": ch, "files": self.audio_files(c, line)}
|
||||
while not c.eol():
|
||||
if c.keyword("fadein"):
|
||||
@@ -737,7 +967,7 @@ class RpyParser:
|
||||
c.keyword("jump")
|
||||
if c.keyword("expression"):
|
||||
return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}]
|
||||
return [{"op": "jump", "target": c.label_name()}]
|
||||
return [{"op": "jump", "target": self.label_ref(c.label_name() or "")}]
|
||||
|
||||
def st_call(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
@@ -748,10 +978,12 @@ class RpyParser:
|
||||
if c.keyword("expression"):
|
||||
node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line,
|
||||
const(""))}
|
||||
c.keyword("pass")
|
||||
else:
|
||||
node = {"op": "call", "target": c.label_name()}
|
||||
if c.peek("("):
|
||||
self.d.warn("Аргументы call не поддерживаются", self.where(line))
|
||||
node = {"op": "call", "target": self.label_ref(c.label_name() or "")}
|
||||
args = self.call_args(c, line)
|
||||
if args:
|
||||
node["args"] = args
|
||||
return [node]
|
||||
|
||||
def st_return(self, line: Line) -> list[Node]:
|
||||
@@ -791,11 +1023,19 @@ class RpyParser:
|
||||
|
||||
def st_menu(self, line: Line) -> list[Node]:
|
||||
node: Node = {"op": "menu", "items": [], "say": None}
|
||||
head = self.cursor(line)
|
||||
head.keyword("menu")
|
||||
name = head.label_name()
|
||||
out: list[Node] = []
|
||||
if name:
|
||||
out.append({"op": "label", "name": self.label_ref(name)})
|
||||
for child in line.block:
|
||||
c = self.cursor(child)
|
||||
if c.keyword("set"):
|
||||
node["set"] = self.expr(c.rest(), child)
|
||||
continue
|
||||
if c.keyword("with"):
|
||||
continue
|
||||
text = c.string()
|
||||
if text is not None and child.text.rstrip().endswith(":"):
|
||||
cond = None
|
||||
@@ -813,7 +1053,7 @@ class RpyParser:
|
||||
else:
|
||||
self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}")
|
||||
self.d.stats["menu"] += 1
|
||||
return [node]
|
||||
return out + [node]
|
||||
|
||||
# say ------------------------------------------------------------------------------
|
||||
|
||||
@@ -871,6 +1111,11 @@ class RpyParser:
|
||||
|
||||
|
||||
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
||||
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
||||
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
||||
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
||||
"ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip",
|
||||
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
||||
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
||||
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
|
||||
|
||||
@@ -880,6 +1125,13 @@ def _dedent(source: str) -> str:
|
||||
return textwrap.dedent(source)
|
||||
|
||||
|
||||
def _parse_py(source: str, mode: str = "exec") -> ast.AST:
|
||||
# decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
return ast.parse(_dedent(source), mode=mode)
|
||||
|
||||
|
||||
def _dotted_name(e: Expr) -> str:
|
||||
if e["k"] == "name":
|
||||
return e["id"]
|
||||
|
||||
@@ -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())
|
||||
@@ -72,7 +72,7 @@ class GameVersion(Base):
|
||||
owner_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"))
|
||||
name: Mapped[str] = mapped_column(String(255))
|
||||
description: Mapped[str] = mapped_column(Text, default="")
|
||||
# uploading -> queued -> converting -> ready | failed
|
||||
# uploading -> queued -> converting -> ready | failed; importing = app.import_dir run
|
||||
status: Mapped[str] = mapped_column(String(16), default="uploading", index=True)
|
||||
progress: Mapped[str] = mapped_column(String(255), default="")
|
||||
upload_name: Mapped[str | None] = mapped_column(String(255))
|
||||
|
||||
@@ -140,7 +140,7 @@ async def delete_version(version_id: int, user: CurrentUser, db: DB) -> None:
|
||||
v = await get_version(db, user, version_id)
|
||||
if not can_manage(user, v):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, "Нет прав на эту версию")
|
||||
if v.status == "converting":
|
||||
if v.status in ("converting", "importing"):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Версия сейчас конвертируется")
|
||||
await db.delete(v)
|
||||
await db.commit()
|
||||
|
||||
+21
-13
@@ -3,6 +3,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select, update
|
||||
|
||||
@@ -41,34 +42,39 @@ async def claim() -> GameVersion | None:
|
||||
|
||||
|
||||
async def process(v: GameVersion) -> None:
|
||||
settings = get_settings()
|
||||
ok = await convert(v.id, upload_path(v.id), v.upload_name or "")
|
||||
if ok:
|
||||
remove_upload(v.id) # sources are not kept once converted
|
||||
|
||||
|
||||
async def convert(version_id: int, source: Path, filename: str) -> bool:
|
||||
"""Run the converter for a version and store the outcome. Returns success."""
|
||||
loop = asyncio.get_running_loop()
|
||||
work = settings.work_dir / str(v.id)
|
||||
work = get_settings().work_dir / str(version_id)
|
||||
|
||||
def progress(text: str) -> None:
|
||||
asyncio.run_coroutine_threadsafe(set_progress(v.id, text), loop)
|
||||
asyncio.run_coroutine_threadsafe(set_progress(version_id, text), loop)
|
||||
|
||||
log.info("converting version %s (%s)", v.id, v.upload_name)
|
||||
log.info("converting version %s (%s)", version_id, filename)
|
||||
try:
|
||||
result = await asyncio.to_thread(convert_archive, upload_path(v.id), v.upload_name or "",
|
||||
work, progress)
|
||||
result = await asyncio.to_thread(convert_archive, source, filename, work, progress)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("version %s failed", v.id)
|
||||
log.exception("version %s failed", version_id)
|
||||
async with SessionLocal() as db:
|
||||
version = await db.get(GameVersion, v.id)
|
||||
version = await db.get(GameVersion, version_id)
|
||||
if version:
|
||||
version.status = "failed"
|
||||
version.progress = f"Ошибка: {e}"[:255]
|
||||
version.report = {"error": str(e)}
|
||||
await db.commit()
|
||||
return
|
||||
return False
|
||||
finally:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
|
||||
async with SessionLocal() as db:
|
||||
version = await db.get(GameVersion, v.id)
|
||||
version = await db.get(GameVersion, version_id)
|
||||
if version is None: # deleted while converting
|
||||
return
|
||||
return True
|
||||
version.status = "ready"
|
||||
version.progress = ""
|
||||
version.manifest = result.manifest
|
||||
@@ -81,9 +87,11 @@ async def process(v: GameVersion) -> None:
|
||||
read_bytes(result.cover_source))
|
||||
except OSError:
|
||||
pass
|
||||
if game and result.title and game.title == source.name:
|
||||
game.title = result.title
|
||||
await db.commit()
|
||||
remove_upload(v.id) # sources are not kept once converted
|
||||
log.info("version %s ready: %s", v.id, result.report.get("media"))
|
||||
log.info("version %s ready: %s", version_id, result.report.get("media"))
|
||||
return True
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
|
||||
@@ -79,3 +79,44 @@ def test_full_pipeline(data_dir, tmp_path):
|
||||
assert [lbl["name"] for lbl in script["labels"]] == ["start", "walk", "stay_home", "ending"]
|
||||
if shutil.which("ffmpeg"):
|
||||
assert "audio/theme.ogg" in files
|
||||
|
||||
|
||||
def test_nested_labels_and_named_menus_become_anchors():
|
||||
from app.converter.pipeline import anchors
|
||||
d = parse("label start:\n if x:\n label inner:\n \"a\"\n"
|
||||
" menu pick:\n \"A\":\n jump .loc\n"
|
||||
"label .loc:\n return\n")
|
||||
labels = d.modules["t.rpy"]
|
||||
assert [lbl["name"] for lbl in labels] == ["start", "inner.loc"] # local labels follow the last global one
|
||||
found = anchors(labels)
|
||||
assert found["inner"] == {"label": "start", "path": [0, 0, 0], "params": None}
|
||||
assert found["pick"]["path"] == [1]
|
||||
assert labels[0]["body"][2]["items"][0]["body"][0]["target"] == "inner.loc"
|
||||
|
||||
|
||||
def test_call_arguments_and_label_params():
|
||||
d = parse('label start:\n call greet("Bob", loud=True) from _c1\n'
|
||||
'label greet(name, loud=False):\n "Hi [name]"\n return\n')
|
||||
call = d.modules["t.rpy"][0]["body"][0]
|
||||
assert call["target"] == "greet" and call["args"]["a"][0]["v"] == "Bob"
|
||||
assert "loud" in call["args"]["kw"]
|
||||
params = d.modules["t.rpy"][1]["params"]
|
||||
assert params[0] == ["name", None] and params[1][1]["v"] is False
|
||||
|
||||
|
||||
def test_atl_frames_layeredimage_and_channels():
|
||||
d = parse('image blink:\n "a.png" with dissolve\n pause 0.5\n "b.png"\n 0.25\n'
|
||||
' repeat\n'
|
||||
'image vid:\n Movie(play="v.webm")\n'
|
||||
'layeredimage eileen:\n always:\n "eileen_base"\n'
|
||||
' group face:\n attribute happy default\n attribute sad "sad.png"\n'
|
||||
'init python:\n renpy.music.register_channel("amb", mixer="sfx", loop=True)\n')
|
||||
blink = d.images["blink"]
|
||||
assert [f.get("d") for f in blink["frames"]] == [0.5, 0.25] and blink["repeat"]
|
||||
assert d.images["vid"] == {"movie": "v.webm"}
|
||||
layers = d.images["eileen"]["layered"]
|
||||
assert layers[0] == {"always": {"ref": "eileen_base"}}
|
||||
assert layers[1] == {"attr": "happy", "group": "face", "default": True,
|
||||
"img": {"ref": "eileen_face_happy"}}
|
||||
assert layers[2]["img"] == {"file": "sad.png"}
|
||||
assert d.channels["amb"] == {"mixer": "sfx", "loop": True}
|
||||
+1
-1
@@ -26,7 +26,7 @@ export interface Version {
|
||||
owner: { id: number; username: string } | null;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "uploading" | "queued" | "converting" | "ready" | "failed";
|
||||
status: "uploading" | "queued" | "converting" | "importing" | "ready" | "failed";
|
||||
progress: string;
|
||||
size_bytes: number;
|
||||
created_at: string;
|
||||
|
||||
@@ -7,6 +7,7 @@ const STATUS: Record<Version["status"], string> = {
|
||||
uploading: "загружается",
|
||||
queued: "в очереди",
|
||||
converting: "конвертируется",
|
||||
importing: "импортируется",
|
||||
ready: "готова",
|
||||
failed: "ошибка",
|
||||
};
|
||||
@@ -32,7 +33,7 @@ export function GamePage() {
|
||||
}, [load]);
|
||||
|
||||
// poll while something is being converted
|
||||
const busy = game?.versions.some((v) => ["uploading", "queued", "converting"].includes(v.status));
|
||||
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status));
|
||||
useEffect(() => {
|
||||
if (!busy) return;
|
||||
const t = setInterval(() => void load(), 2000);
|
||||
@@ -154,7 +155,7 @@ function VersionRow(props: {
|
||||
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
|
||||
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</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>
|
||||
{report !== undefined && <Report report={report} />}
|
||||
|
||||
@@ -32,11 +32,13 @@ function fade(el: HTMLAudioElement, from: number, to: number, seconds: number, d
|
||||
export class AudioSystem {
|
||||
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";
|
||||
if (ch === "voice") return "voice";
|
||||
const mixer = this.registered[ch]?.mixer ?? ch;
|
||||
if (mixer === "music") return "music";
|
||||
if (mixer === "voice") return "voice";
|
||||
return "sound";
|
||||
}
|
||||
|
||||
@@ -52,7 +54,7 @@ export class AudioSystem {
|
||||
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
|
||||
ifChanged?: boolean }) {
|
||||
const c = this.channel(ch);
|
||||
const loop = opts.loop ?? ch === "music";
|
||||
const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music";
|
||||
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
|
||||
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
|
||||
c.queue = files.slice(1);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, ty
|
||||
import { Renderer, type ResolvedImage } from "./renderer";
|
||||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||
import type {
|
||||
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Manifest, Node, SaveData, ScriptModule,
|
||||
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
|
||||
} from "./types";
|
||||
|
||||
export interface SaveMeta {
|
||||
@@ -63,6 +63,7 @@ function saveJSON(key: string, value: unknown) {
|
||||
|
||||
export class Engine {
|
||||
private labels = new Map<string, LabelRef>();
|
||||
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
|
||||
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
|
||||
private store: Record<string, any> = {};
|
||||
private builtins: Record<string, any> = {};
|
||||
@@ -117,6 +118,7 @@ export class Engine {
|
||||
else global = label.name.split(".")[0];
|
||||
this.labels.set(label.name, { label, module: mod, index });
|
||||
});
|
||||
for (const [name, anchor] of Object.entries(mod.anchors ?? {})) this.anchors.set(name, anchor);
|
||||
}
|
||||
for (const key of Object.keys(game.images)) {
|
||||
const [tag, ...attrs] = key.split(" ");
|
||||
@@ -144,8 +146,9 @@ export class Engine {
|
||||
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
|
||||
evalCond: (c) => truthy(this.safeEval(c)),
|
||||
transform: (name) => game.transforms[name] ?? null,
|
||||
text: (e) => this.interp(pyStr(this.safeEval(e))),
|
||||
});
|
||||
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f));
|
||||
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
|
||||
this.buildUI();
|
||||
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
|
||||
this.resizeObserver.observe(this.container);
|
||||
@@ -182,8 +185,10 @@ export class Engine {
|
||||
|
||||
private resolveImage(name: string[]): ResolvedImage | null {
|
||||
const full = name.join(" ");
|
||||
if (this.game.images[full]) return { def: this.game.images[full], name };
|
||||
const [tag, ...attrs] = name;
|
||||
const layered = this.game.images[tag];
|
||||
if (layered?.layered) return this.resolveLayered(tag, layered, attrs);
|
||||
if (this.game.images[full]) return { def: this.game.images[full], name };
|
||||
const candidates = this.imagesByTag.get(tag) ?? [];
|
||||
let best: { attrs: string[]; key: string } | null = null;
|
||||
for (const c of candidates) {
|
||||
@@ -202,9 +207,35 @@ export class Engine {
|
||||
return { def: this.game.images[best.key], name: best.key.split(" ") };
|
||||
}
|
||||
|
||||
/** Attributes of a layeredimage: new ones replace current ones of the same group. */
|
||||
private resolveLayered(tag: string, def: ImageDef, attrs: string[]): ResolvedImage {
|
||||
const layers = def.layered!;
|
||||
const groupOf = (a: string) => layers.find((l) => l.attr === a)?.group ?? null;
|
||||
const removed = attrs.filter((a) => a.startsWith("-")).map((a) => a.slice(1));
|
||||
const added = attrs.filter((a) => !a.startsWith("-"));
|
||||
const current = (this.renderer.attrsOf(tag)?.slice(1) ?? []).filter((a) => {
|
||||
if (removed.includes(a)) return false;
|
||||
const g = groupOf(a);
|
||||
return !added.some((n) => n === a || (g !== null && groupOf(n) === g));
|
||||
});
|
||||
const selected = [...current, ...added];
|
||||
// `group x auto`: attributes backed by images named <tag>_<group>_<attr>
|
||||
const extra: Layer[] = [];
|
||||
for (const l of layers) {
|
||||
if (!l.autoGroup) continue;
|
||||
for (const a of selected) {
|
||||
const ref = `${tag}_${l.autoGroup}_${a}`;
|
||||
if (this.game.images[ref] && !layers.some((x) => x.attr === a))
|
||||
extra.push({ attr: a, group: l.autoGroup, img: { ref } });
|
||||
}
|
||||
}
|
||||
return { def: { ...def, layered: [...layers, ...extra], selected }, name: [tag, ...selected] };
|
||||
}
|
||||
|
||||
private imageFromSpec(spec: any): ResolvedImage | null {
|
||||
if (spec.name) return this.resolveImage(spec.name);
|
||||
if (spec.text !== undefined) return { def: { text: spec.text }, name: ["text"] };
|
||||
if (spec.textExpr) return { def: { textExpr: spec.textExpr }, name: ["text"] };
|
||||
if (spec.expr) {
|
||||
const v = this.safeEval(spec.expr);
|
||||
if (typeof v !== "string") return null;
|
||||
@@ -424,26 +455,52 @@ export class Engine {
|
||||
this.settle();
|
||||
}
|
||||
|
||||
private jump(target: string) {
|
||||
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
|
||||
if (target === "__main_menu__" || target === "__quit__") {
|
||||
throw new Restart(target);
|
||||
}
|
||||
let name = target;
|
||||
if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name;
|
||||
if (!this.labels.has(name)) {
|
||||
const anchor = this.labels.has(name) ? null : this.anchors.get(name);
|
||||
if (!this.labels.has(name) && !anchor) {
|
||||
this.notify(`Метка «${target}» не найдена`);
|
||||
console.warn("[pgvn] нет метки", target);
|
||||
if (frame) this.stack.pop();
|
||||
this.advance();
|
||||
return;
|
||||
}
|
||||
this.cur = { label: name, path: [0] };
|
||||
const params = anchor ? anchor.params : this.labels.get(name)!.label.params;
|
||||
if (params?.length) this.bindParams(params, args, frame);
|
||||
this.cur = anchor ? { label: anchor.label, path: [...anchor.path] } : { label: name, path: [0] };
|
||||
this.markSeen(name);
|
||||
this.settle();
|
||||
}
|
||||
|
||||
private call(target: string) {
|
||||
this.stack.push({ label: this.cur.label, path: [...this.cur.path] });
|
||||
this.jump(target);
|
||||
/** Label parameters are dynamically scoped: a call restores old values on return. */
|
||||
private bindParams(params: [string, Expr | null][], args?: { a: any[]; kw: Record<string, any> },
|
||||
frame?: Frame) {
|
||||
const values: Record<string, any> = {};
|
||||
params.forEach(([p, def], i) => {
|
||||
if (args && i < args.a.length) values[p] = args.a[i];
|
||||
else if (args && p in args.kw) values[p] = args.kw[p];
|
||||
else values[p] = def ? this.safeEval(def) : null;
|
||||
});
|
||||
if (frame) {
|
||||
frame.saved = {};
|
||||
for (const p of Object.keys(values)) frame.saved[p] = { had: p in this.store, value: this.store[p] };
|
||||
}
|
||||
Object.assign(this.store, values);
|
||||
}
|
||||
|
||||
private call(target: string, argExprs?: { a: Expr[]; kw: Record<string, Expr> }) {
|
||||
// arguments are evaluated in the caller's scope, before parameters are bound
|
||||
const args = argExprs && {
|
||||
a: argExprs.a.map((e) => this.safeEval(e)),
|
||||
kw: Object.fromEntries(Object.entries(argExprs.kw).map(([k, e]) => [k, this.safeEval(e)])),
|
||||
};
|
||||
const frame: Frame = { label: this.cur.label, path: [...this.cur.path] };
|
||||
this.stack.push(frame);
|
||||
this.jump(target, args, frame);
|
||||
}
|
||||
|
||||
private doReturn() {
|
||||
@@ -452,7 +509,11 @@ export class Engine {
|
||||
this.cur = { label: "", path: [0] };
|
||||
return;
|
||||
}
|
||||
this.cur = frame;
|
||||
for (const [p, old] of Object.entries(frame.saved ?? {})) {
|
||||
if (old.had) this.store[p] = old.value;
|
||||
else delete this.store[p];
|
||||
}
|
||||
this.cur = { label: frame.label, path: frame.path };
|
||||
this.advance();
|
||||
}
|
||||
|
||||
@@ -589,7 +650,11 @@ export class Engine {
|
||||
case "jump":
|
||||
return this.jump(node.target ?? pyStr(this.safeEval(node.expr)));
|
||||
case "call":
|
||||
return this.call(node.target ?? pyStr(this.safeEval(node.expr)));
|
||||
return this.call(node.target ?? pyStr(this.safeEval(node.expr)), node.args);
|
||||
case "camera":
|
||||
this.renderer.camera(node.at ?? []);
|
||||
this.advance();
|
||||
return;
|
||||
case "return":
|
||||
this.doReturn();
|
||||
return;
|
||||
@@ -911,6 +976,7 @@ export class Engine {
|
||||
this.restart(() => {
|
||||
this.audio.stopAll();
|
||||
this.renderer.scene();
|
||||
this.renderer.camera([]);
|
||||
this.renderer.commit();
|
||||
this.newStore();
|
||||
this.stack = [];
|
||||
@@ -927,11 +993,12 @@ export class Engine {
|
||||
return {
|
||||
v: 1,
|
||||
cur: { label: this.cur.label, path: [...this.cur.path] },
|
||||
stack: this.stack.map((f) => ({ label: f.label, path: [...f.path] })),
|
||||
stack: this.stack.map((f) => ({ label: f.label, path: [...f.path], saved: encodeValue(f.saved) })),
|
||||
store: encodeValue(this.store),
|
||||
scene: this.renderer.snapshot(),
|
||||
music: this.audio.current("music"),
|
||||
window: this.windowShown,
|
||||
camera: this.renderer.cameraAt,
|
||||
text: this.lastSay ? `${this.lastSay.name ? this.lastSay.name + ": " : ""}${plainText(parseText(this.lastSay.text))}` : "",
|
||||
};
|
||||
}
|
||||
@@ -970,7 +1037,8 @@ export class Engine {
|
||||
this.audio.stopAll();
|
||||
if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 });
|
||||
this.windowShown = data.window;
|
||||
this.stack = data.stack;
|
||||
this.renderer.camera(data.camera ?? []);
|
||||
this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) }));
|
||||
this.cur = data.cur;
|
||||
this.lastSay = null;
|
||||
this.history = [];
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface RendererHost {
|
||||
resolveRef(name: string): ImageDef | null;
|
||||
evalCond(e: any): boolean;
|
||||
transform(name: string): Record<string, any> | null;
|
||||
text(e: any): string;
|
||||
}
|
||||
|
||||
interface Entry extends ShownImage {
|
||||
@@ -148,12 +149,14 @@ export class Renderer {
|
||||
v.playsInline = true;
|
||||
return v;
|
||||
}
|
||||
if (def.text !== undefined) {
|
||||
if (def.text !== undefined || def.textExpr) {
|
||||
const d = document.createElement("div");
|
||||
d.className = "pg-show-text";
|
||||
d.textContent = def.text;
|
||||
d.textContent = def.textExpr ? this.host.text(def.textExpr) : def.text!;
|
||||
return d;
|
||||
}
|
||||
if (def.frames?.length) return this.animated(def, depth);
|
||||
if (def.layered) return this.layered(def, depth);
|
||||
if (def.file) {
|
||||
const img = document.createElement("img");
|
||||
img.draggable = false;
|
||||
@@ -170,9 +173,85 @@ export class Renderer {
|
||||
return document.createElement("div");
|
||||
}
|
||||
|
||||
/** Frame-by-frame ATL animation: swaps children on timers while attached. */
|
||||
private animated(def: ImageDef, depth: number): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.style.position = "relative";
|
||||
const frames = def.frames!;
|
||||
let i = 0;
|
||||
let started = false;
|
||||
box.append(this.build(frames[0].img, depth + 1));
|
||||
const tick = () => {
|
||||
if (box.isConnected) started = true;
|
||||
else if (started) return;
|
||||
const d = frames[i].d ?? 0;
|
||||
const last = i === frames.length - 1;
|
||||
if (!d || (last && !def.repeat)) return;
|
||||
window.setTimeout(() => {
|
||||
if (started && !box.isConnected) return;
|
||||
i = last ? 0 : i + 1;
|
||||
box.replaceChildren(this.build(frames[i].img, depth + 1));
|
||||
tick();
|
||||
}, d * 1000);
|
||||
};
|
||||
tick();
|
||||
return box;
|
||||
}
|
||||
|
||||
/** layeredimage: stack the layers selected by the image's attributes. */
|
||||
private layered(def: ImageDef, depth: number): HTMLElement {
|
||||
const box = document.createElement("div");
|
||||
box.style.position = "relative";
|
||||
const sel = new Set(def.selected ?? []);
|
||||
const groupsSelected = new Set(
|
||||
def.layered!.filter((l) => l.attr && sel.has(l.attr) && l.group).map((l) => l.group));
|
||||
const imgs: ImageDef[] = [];
|
||||
for (const layer of def.layered!) {
|
||||
if (layer.always) imgs.push(layer.always);
|
||||
else if (layer.cond) {
|
||||
const hit = layer.cond.find(([c]) => c === null || this.host.evalCond(c));
|
||||
if (hit) imgs.push(hit[1]);
|
||||
} else if (layer.attr !== undefined) {
|
||||
const on = sel.has(layer.attr) || (!!layer.default && !(layer.group && groupsSelected.has(layer.group)));
|
||||
if (!on || !layer.img || layer.null) continue;
|
||||
if (layer.if_any && !layer.if_any.some((a) => sel.has(a))) continue;
|
||||
if (layer.if_all && !layer.if_all.every((a) => sel.has(a))) continue;
|
||||
if (layer.if_not && layer.if_not.some((a) => sel.has(a))) continue;
|
||||
imgs.push(layer.img);
|
||||
}
|
||||
}
|
||||
imgs.forEach((img, n) => {
|
||||
const child = this.build(img, depth + 1);
|
||||
if (n > 0) {
|
||||
child.style.position = "absolute";
|
||||
child.style.left = "0";
|
||||
child.style.top = "0";
|
||||
}
|
||||
box.append(child);
|
||||
});
|
||||
return box;
|
||||
}
|
||||
|
||||
camera(at: AtSpec[]) {
|
||||
const p: Record<string, any> = {};
|
||||
for (const spec of at) Object.assign(p, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
|
||||
const zoom = p.zoom ?? 1;
|
||||
this.master.style.transformOrigin = "50% 50%";
|
||||
this.master.style.transform = `translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px) scale(${zoom})`;
|
||||
this.cameraAt = at;
|
||||
}
|
||||
|
||||
cameraAt: AtSpec[] = [];
|
||||
|
||||
private place(el: HTMLElement, def: ImageDef, at: AtSpec[]) {
|
||||
const p: Record<string, any> = { ...POSITIONS.default, ...(def.props ?? {}) };
|
||||
for (const spec of at) {
|
||||
for (const spec of [...(def.at ?? []), ...at]) {
|
||||
if (spec.effect === "shake") {
|
||||
const dist = spec.dist ?? 20;
|
||||
el.animate([0, dist, -dist, dist * 0.6, -dist * 0.6, 0].map((x) => ({ translate: `${x}px 0` })),
|
||||
{ duration: (spec.d ?? 0.5) * 1000 });
|
||||
continue;
|
||||
}
|
||||
if (spec.pos) Object.assign(p, POSITIONS[spec.pos] ?? {});
|
||||
if (spec.name && !spec.props) Object.assign(p, this.host.transform(spec.name) ?? POSITIONS[spec.name] ?? {});
|
||||
if (spec.props) Object.assign(p, spec.props);
|
||||
|
||||
@@ -24,13 +24,36 @@ export interface ImageDef {
|
||||
composite?: { pos: [number, number]; img: ImageDef }[];
|
||||
size?: [number, number] | null;
|
||||
text?: string;
|
||||
textExpr?: Expr;
|
||||
props?: Record<string, any>;
|
||||
frames?: { img: ImageDef; d?: number }[];
|
||||
repeat?: boolean;
|
||||
layered?: Layer[];
|
||||
at?: AtSpec[];
|
||||
selected?: string[]; // attributes chosen for a layered image (set by the engine)
|
||||
}
|
||||
|
||||
export interface Layer {
|
||||
always?: ImageDef;
|
||||
attr?: string;
|
||||
group?: string | null;
|
||||
default?: boolean;
|
||||
null?: boolean;
|
||||
img?: ImageDef;
|
||||
if_any?: string[];
|
||||
if_all?: string[];
|
||||
if_not?: string[];
|
||||
cond?: [Expr | null, ImageDef][];
|
||||
autoGroup?: string;
|
||||
}
|
||||
|
||||
export interface AtSpec {
|
||||
pos?: string;
|
||||
name?: string;
|
||||
props?: Record<string, any>;
|
||||
effect?: "shake";
|
||||
d?: number;
|
||||
dist?: number;
|
||||
}
|
||||
|
||||
export interface CharacterDef {
|
||||
@@ -53,11 +76,19 @@ export type Node = { op: string; [key: string]: any };
|
||||
export interface Label {
|
||||
name: string;
|
||||
body: Node[];
|
||||
params?: [string, Expr | null][];
|
||||
}
|
||||
|
||||
export interface Anchor {
|
||||
label: string;
|
||||
path: number[];
|
||||
params?: [string, Expr | null][] | null;
|
||||
}
|
||||
|
||||
export interface ScriptModule {
|
||||
file: string;
|
||||
labels: Label[];
|
||||
anchors?: Record<string, Anchor>;
|
||||
}
|
||||
|
||||
export interface GameData {
|
||||
@@ -71,6 +102,7 @@ export interface GameData {
|
||||
transforms: Record<string, Record<string, any>>;
|
||||
transitions: Record<string, Transition | null>;
|
||||
audio: Record<string, string>;
|
||||
channels?: Record<string, { mixer: string; loop: boolean }>;
|
||||
files: Record<string, string>;
|
||||
defaults: [string, Expr][];
|
||||
init: PyStmt[];
|
||||
@@ -85,6 +117,8 @@ export interface Manifest {
|
||||
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 {
|
||||
@@ -103,6 +137,7 @@ export interface SaveData {
|
||||
scene: ShownImage[];
|
||||
music: { file: string; loop: boolean } | null;
|
||||
window: boolean;
|
||||
camera?: AtSpec[];
|
||||
menuSeen?: string[];
|
||||
text?: string;
|
||||
}
|
||||
@@ -109,7 +109,7 @@ button.link:hover { color: var(--text); }
|
||||
.status { margin-left: 0.6rem; font-size: 0.8rem; padding: 0.05rem 0.5rem; border-radius: 99px; background: var(--panel-2); }
|
||||
.status-ready { color: var(--ok); }
|
||||
.status-failed { color: var(--danger); }
|
||||
.status-queued, .status-converting, .status-uploading { color: var(--accent); }
|
||||
.status-queued, .status-converting, .status-importing, .status-uploading { color: var(--accent); }
|
||||
.report { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid var(--border); font-size: 0.9rem; }
|
||||
.report-stats { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin-bottom: 0.5rem; }
|
||||
.report table { border-collapse: collapse; margin-top: 0.4rem; }
|
||||
|
||||
Reference in new issue
Block a user