Initial MVP: local visual novel library with Ren'Py → PGVN converter
- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -0,0 +1,974 @@
|
||||
"""Ren'Py script (.rpy) → PGVN IR."""
|
||||
|
||||
import ast
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.converter import pyexpr
|
||||
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
|
||||
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
||||
|
||||
Node = dict[str, Any]
|
||||
|
||||
TRANSITIONS: dict[str, dict[str, Any]] = {
|
||||
"dissolve": {"t": "dissolve", "d": 0.5},
|
||||
"fade": {"t": "fade", "out": 0.5, "hold": 0.0, "in": 0.5, "color": "#000000"},
|
||||
"pixellate": {"t": "dissolve", "d": 1.0},
|
||||
"move": {"t": "move", "d": 0.5},
|
||||
"ease": {"t": "move", "d": 0.5},
|
||||
"vpunch": {"t": "punch", "dir": "v", "d": 0.3},
|
||||
"hpunch": {"t": "punch", "dir": "h", "d": 0.3},
|
||||
"blinds": {"t": "dissolve", "d": 1.0},
|
||||
"squares": {"t": "dissolve", "d": 1.0},
|
||||
"wipeleft": {"t": "wipe", "dir": "left", "d": 1.0},
|
||||
"wiperight": {"t": "wipe", "dir": "right", "d": 1.0},
|
||||
"wipeup": {"t": "wipe", "dir": "up", "d": 1.0},
|
||||
"wipedown": {"t": "wipe", "dir": "down", "d": 1.0},
|
||||
"zoomin": {"t": "dissolve", "d": 0.5},
|
||||
"zoomout": {"t": "dissolve", "d": 0.5},
|
||||
}
|
||||
for _side in ("left", "right", "top", "bottom"):
|
||||
for _kind in ("movein", "moveout", "easein", "easeout", "slide", "slideaway",
|
||||
"push", "irisin", "irisout"):
|
||||
TRANSITIONS.setdefault(f"{_kind}{_side}", {"t": "slide", "dir": _side, "d": 0.5,
|
||||
"kind": _kind})
|
||||
TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pixellate",
|
||||
"MoveTransition", "CropMove", "PushMove", "ComposeTransition",
|
||||
"MultipleTransition", "Move"}
|
||||
|
||||
POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top",
|
||||
"offscreenleft", "offscreenright", "default", "reset"}
|
||||
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
|
||||
"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"}
|
||||
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScriptData:
|
||||
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
images: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
|
||||
audio: dict[str, str] = field(default_factory=dict)
|
||||
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)
|
||||
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)
|
||||
warning_examples: dict[str, str] = field(default_factory=dict)
|
||||
stats: Counter = field(default_factory=Counter)
|
||||
|
||||
def warn(self, kind: str, where: str = "") -> None:
|
||||
self.warnings[kind] += 1
|
||||
if where and kind not in self.warning_examples:
|
||||
self.warning_examples[kind] = where
|
||||
|
||||
|
||||
class RpyParser:
|
||||
def __init__(self, data: ScriptData, filename: str):
|
||||
self.d = data
|
||||
self.filename = filename
|
||||
|
||||
# --- helpers -------------------------------------------------------------
|
||||
|
||||
def where(self, line: Line) -> str:
|
||||
return f"{self.filename}:{line.lineno}"
|
||||
|
||||
def cursor(self, line: Line, text: str | None = None) -> Cursor:
|
||||
return Cursor(line.text if text is None else text, self.filename, line.lineno)
|
||||
|
||||
def expr(self, source: str, line: Line, default: Expr | None = None) -> Expr | None:
|
||||
try:
|
||||
return pyexpr.parse_expr(source)
|
||||
except Unsupported as e:
|
||||
self.d.warn(f"Выражение: {e}", self.where(line))
|
||||
return default
|
||||
|
||||
def python(self, source: str, line: Line) -> list[Stmt]:
|
||||
"""Translate python code statement by statement, skipping unsupported ones."""
|
||||
try:
|
||||
tree = ast.parse(_dedent(source))
|
||||
except SyntaxError as e:
|
||||
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
|
||||
return []
|
||||
out: list[Stmt] = []
|
||||
for node in tree.body:
|
||||
try:
|
||||
out.extend(pyexpr.stmt(node))
|
||||
except Unsupported as e:
|
||||
self.d.warn(f"Python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
|
||||
return out
|
||||
|
||||
# --- top level -------------------------------------------------------------
|
||||
|
||||
def parse(self, source: str) -> None:
|
||||
tree = parse_tree(source, self.filename)
|
||||
labels: list[Node] = []
|
||||
current: Node | None = None
|
||||
for line in tree:
|
||||
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)}
|
||||
labels.append(current)
|
||||
continue
|
||||
nodes = self.statement(line, top_level=True)
|
||||
if nodes and current is not None:
|
||||
current["body"].extend(nodes)
|
||||
elif nodes:
|
||||
# statements before any label run on fall-through into the first label
|
||||
labels.append(current := {"name": f"_{self.filename}_{line.lineno}",
|
||||
"body": nodes})
|
||||
self.d.modules[self.filename] = labels
|
||||
|
||||
def block(self, lines: list[Line]) -> list[Node]:
|
||||
out: list[Node] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
word = Cursor(line.text).peek_word()
|
||||
if word == "if":
|
||||
branches, i = self.if_chain(lines, i)
|
||||
out.append({"op": "if", "branches": branches})
|
||||
continue
|
||||
if word == "label":
|
||||
self.d.warn("Вложенные метки не поддерживаются", self.where(line))
|
||||
out.extend(self.block(line.block))
|
||||
i += 1
|
||||
continue
|
||||
out.extend(self.statement(line))
|
||||
i += 1
|
||||
return out
|
||||
|
||||
def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]:
|
||||
branches: list[Node] = []
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
c = self.cursor(line)
|
||||
kw = c.word()
|
||||
if kw in ("if", "elif") and (kw == "if") == (not branches):
|
||||
cond = c.rest().removesuffix(":").strip()
|
||||
branches.append({"c": self.expr(cond, line, const(False)),
|
||||
"body": self.block(line.block)})
|
||||
elif kw == "else" and branches:
|
||||
branches.append({"c": None, "body": self.block(line.block)})
|
||||
i += 1
|
||||
break
|
||||
else:
|
||||
break
|
||||
i += 1
|
||||
return branches, i
|
||||
|
||||
# --- statements ----------------------------------------------------------------
|
||||
|
||||
def statement(self, line: Line, top_level: bool = False) -> list[Node]:
|
||||
text = line.text
|
||||
if text.startswith("$"):
|
||||
return self.dollar(text[1:].strip(), line)
|
||||
c = self.cursor(line)
|
||||
word = c.peek_word()
|
||||
handler = getattr(self, f"st_{word}", None) if word else None
|
||||
if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)):
|
||||
try:
|
||||
return handler(line) or []
|
||||
except ParseError:
|
||||
raise
|
||||
if word in SKIP_BLOCKS:
|
||||
if word not in ("style", "translate"):
|
||||
self.d.warn(f"Не поддерживается: {word}", self.where(line))
|
||||
return []
|
||||
say = self.say(line)
|
||||
if say is not None:
|
||||
return say
|
||||
self.d.warn("Неизвестный оператор", f"{self.where(line)}: {text[:60]}")
|
||||
return []
|
||||
|
||||
def _is_say(self, line: Line) -> bool:
|
||||
c = self.cursor(line)
|
||||
c.word()
|
||||
while c.image_part() is not None and not c.peek('"') and not c.peek("'"):
|
||||
pass
|
||||
return c.string_raw() is not None or c.peek('"') or c.peek("'")
|
||||
|
||||
def dollar(self, code: str, line: Line) -> list[Node]:
|
||||
stmts = self.python(code, line)
|
||||
if len(stmts) == 1 and stmts[0]["op"] == "eval":
|
||||
special = self.renpy_call(stmts[0]["e"], line)
|
||||
if special is not None:
|
||||
return special
|
||||
return [{"op": "exec", "code": stmts}] if stmts else []
|
||||
|
||||
def renpy_call(self, e: Expr, line: Line) -> list[Node] | None:
|
||||
"""Turn common renpy.* calls into native script nodes."""
|
||||
if e.get("k") != "call":
|
||||
return None
|
||||
fname = _dotted_name(e["f"])
|
||||
args, kw = e["a"], e["kw"]
|
||||
if fname == "renpy.pause":
|
||||
return [{"op": "pause", "d": args[0] if args else kw.get("delay")}]
|
||||
if fname == "renpy.jump" and args:
|
||||
return [_jump_or_call("jump", args[0])]
|
||||
if fname == "renpy.call" and args:
|
||||
return [_jump_or_call("call", args[0])]
|
||||
if fname in ("renpy.music.play", "renpy.sound.play", "renpy.play"):
|
||||
ch = "sound" if fname != "renpy.music.play" else "music"
|
||||
if "channel" in kw and kw["channel"].get("k") == "const":
|
||||
ch = kw["channel"]["v"]
|
||||
if not args:
|
||||
return None
|
||||
node = {"op": "play", "ch": ch, "files": [args[0]], "loop": _const(kw.get("loop"))}
|
||||
for key in ("fadein", "fadeout"):
|
||||
if key in kw:
|
||||
node[key] = _const(kw[key])
|
||||
return [node]
|
||||
if fname in ("renpy.music.stop", "renpy.sound.stop"):
|
||||
ch = "music" if fname == "renpy.music.stop" else "sound"
|
||||
if "channel" in kw and kw["channel"].get("k") == "const":
|
||||
ch = kw["channel"]["v"]
|
||||
return [{"op": "stop", "ch": ch, "fadeout": _const(kw.get("fadeout"))}]
|
||||
if fname == "renpy.show" and args and args[0].get("k") == "const":
|
||||
return [{"op": "show", "img": {"name": str(args[0]["v"]).split()}, "at": [],
|
||||
"with": None}]
|
||||
if fname == "renpy.hide" and args and args[0].get("k") == "const":
|
||||
return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}]
|
||||
if fname == "renpy.scene":
|
||||
return [{"op": "scene", "img": None, "at": [], "with": None}]
|
||||
if fname == "renpy.with_statement" and args:
|
||||
return [{"op": "with", "t": self.transition_expr(args[0])}]
|
||||
return None
|
||||
|
||||
# init / python ----------------------------------------------------------------
|
||||
|
||||
def st_init(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("init")
|
||||
c.number()
|
||||
if c.keyword("offset"):
|
||||
return []
|
||||
if c.keyword("python") or c.keyword("early"):
|
||||
c.keyword("python")
|
||||
self.init_python(line.block_source(), line)
|
||||
return []
|
||||
for child in line.block:
|
||||
for node in self.statement(child, top_level=True):
|
||||
if node["op"] == "exec":
|
||||
self.d.init_code.extend(node["code"])
|
||||
else:
|
||||
self.d.warn("Оператор сцены внутри init", self.where(child))
|
||||
return []
|
||||
|
||||
def st_python(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("python")
|
||||
if c.keyword("early"):
|
||||
self.init_python(line.block_source(), line)
|
||||
return []
|
||||
code = self.python(line.block_source(), line)
|
||||
return [{"op": "exec", "code": code}] if code else []
|
||||
|
||||
def st_rpy(self, line: Line) -> list[Node]:
|
||||
return []
|
||||
|
||||
def init_python(self, source: str, line: Line) -> None:
|
||||
try:
|
||||
tree = ast.parse(_dedent(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.Assign) and len(node.targets) == 1:
|
||||
tgt = node.targets[0]
|
||||
name = _assign_name(tgt)
|
||||
if name and self.define(name, ast.get_source_segment(_dedent(source), node.value)
|
||||
or ast.unparse(node.value), line):
|
||||
continue
|
||||
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||
and ast.unparse(node.value.func) == "gui.init"
|
||||
and len(node.value.args) == 2):
|
||||
try:
|
||||
self.d.gui["resolution"] = [ast.literal_eval(a) for a in node.value.args]
|
||||
except ValueError:
|
||||
pass
|
||||
continue
|
||||
try:
|
||||
self.d.init_code.extend(pyexpr.stmt(node))
|
||||
except Unsupported as e:
|
||||
self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
|
||||
|
||||
# define / default / image / transform -------------------------------------------
|
||||
|
||||
def st_define(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("define")
|
||||
c.number()
|
||||
name = c.dotted()
|
||||
if not name:
|
||||
raise c.error("ожидалось имя")
|
||||
op = "="
|
||||
for candidate in ("+=", "|=", "="):
|
||||
if c.char(candidate):
|
||||
op = candidate
|
||||
break
|
||||
value = c.rest()
|
||||
if op != "=":
|
||||
self.d.warn("define с += не поддерживается", self.where(line))
|
||||
return []
|
||||
self.define(name, value, line)
|
||||
return []
|
||||
|
||||
def define(self, name: str, value: str, line: Line) -> bool:
|
||||
"""Handle `define name = value`. Returns False if value was not understood."""
|
||||
head, _, attr = name.partition(".")
|
||||
if head in ("config", "gui", "build", "preferences", "style", "_preferences"):
|
||||
if attr:
|
||||
try:
|
||||
(self.d.config if head == "config" else self.d.gui)[attr] = pyexpr.literal(value)
|
||||
except Unsupported:
|
||||
pass
|
||||
return True
|
||||
if head == "audio" and attr:
|
||||
try:
|
||||
self.d.audio[attr] = str(pyexpr.literal(value))
|
||||
except Unsupported:
|
||||
pass
|
||||
return True
|
||||
if head == "store":
|
||||
name = attr
|
||||
e = self.expr(value, line)
|
||||
if e is None:
|
||||
return False
|
||||
if "." in name:
|
||||
# e.g. persistent.flag = ... : a plain assignment at init time
|
||||
self.d.init_code.append({"op": "set", "t": pyexpr.parse_expr(name), "v": e})
|
||||
return True
|
||||
if e["k"] == "call":
|
||||
fname = _dotted_name(e["f"])
|
||||
if fname in ("Character", "DynamicCharacter", "ADVCharacter", "NVLCharacter"):
|
||||
self.d.characters[name] = self.character(fname, e, line)
|
||||
return True
|
||||
if fname in TRANSITION_CLASSES or fname in TRANSITIONS:
|
||||
self.d.transitions[name] = self.transition_expr(e)
|
||||
return True
|
||||
if fname in ("Transform", "Position"):
|
||||
self.d.transforms[name] = _static_props(e["kw"])
|
||||
return True
|
||||
if e["k"] == "name" and e["id"] in TRANSITIONS:
|
||||
self.d.transitions[name] = TRANSITIONS[e["id"]]
|
||||
return True
|
||||
if e["k"] == "name" and e["id"] in self.d.characters:
|
||||
self.d.characters[name] = dict(self.d.characters[e["id"]])
|
||||
return True
|
||||
self.d.init_code.append({"op": "set", "t": {"k": "name", "id": name}, "v": e})
|
||||
return True
|
||||
|
||||
def character(self, fname: str, e: Expr, line: Line) -> dict[str, Any]:
|
||||
kw = e["kw"]
|
||||
char: dict[str, Any] = {}
|
||||
kind = kw.get("kind")
|
||||
if kind and kind.get("k") == "name":
|
||||
if kind["id"] == "nvl":
|
||||
char["nvl"] = True
|
||||
elif kind["id"] in self.d.characters:
|
||||
char.update(self.d.characters[kind["id"]])
|
||||
if fname == "NVLCharacter":
|
||||
char["nvl"] = True
|
||||
args = e["a"]
|
||||
if args:
|
||||
name = args[0]
|
||||
if fname == "DynamicCharacter" or (kw.get("dynamic") or {}).get("v"):
|
||||
char["nameExpr"] = (self.expr(name["v"], line, const(""))
|
||||
if name.get("k") == "const" and isinstance(name["v"], str)
|
||||
else name)
|
||||
elif name.get("k") == "const":
|
||||
char["name"] = name["v"]
|
||||
else:
|
||||
char["nameExpr"] = name
|
||||
mapping = {"color": "color", "who_color": "color", "what_color": "whatColor",
|
||||
"what_prefix": "whatPrefix", "what_suffix": "whatSuffix",
|
||||
"who_prefix": "whoPrefix", "who_suffix": "whoSuffix", "image": "image",
|
||||
"what_italic": "whatItalic", "what_bold": "whatBold"}
|
||||
for key, target in mapping.items():
|
||||
v = kw.get(key)
|
||||
if v is not None and v.get("k") == "const":
|
||||
char[target] = v["v"]
|
||||
return char
|
||||
|
||||
def st_default(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("default")
|
||||
name = c.dotted()
|
||||
if not name or not c.char("="):
|
||||
raise c.error("ожидалось default имя = значение")
|
||||
e = self.expr(c.rest(), line)
|
||||
if e is not None:
|
||||
self.d.defaults.append([name.removeprefix("store."), e])
|
||||
return []
|
||||
|
||||
def st_image(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("image")
|
||||
parts: list[str] = []
|
||||
while (p := c.image_part()) is not None:
|
||||
parts.append(p)
|
||||
if not parts:
|
||||
raise c.error("ожидалось имя изображения")
|
||||
name = " ".join(parts)
|
||||
if c.char("="):
|
||||
img = self.displayable(c.rest(), line)
|
||||
elif c.char(":"):
|
||||
img = self.atl_image(line)
|
||||
else:
|
||||
raise c.error("ожидалось = или :")
|
||||
if img is not None:
|
||||
self.d.images[name] = img
|
||||
return []
|
||||
|
||||
def displayable(self, source: str, line: Line) -> dict[str, Any] | None:
|
||||
e = self.expr(source, line)
|
||||
return self.displayable_expr(e, line) if e is not None else None
|
||||
|
||||
def displayable_expr(self, e: Expr, line: Line) -> dict[str, Any] | None:
|
||||
if e["k"] == "const" and isinstance(e["v"], str):
|
||||
s = e["v"]
|
||||
if s.startswith("#"):
|
||||
return {"color": s}
|
||||
if "." in s and "/" in s or s.rsplit(".", 1)[-1].lower() in _MEDIA_EXTS:
|
||||
return {"file": s}
|
||||
return {"ref": s}
|
||||
if e["k"] != "call":
|
||||
self.d.warn("Изображение задано выражением", self.where(line))
|
||||
return {"expr": e}
|
||||
fname = _dotted_name(e["f"])
|
||||
args, kw = e["a"], e["kw"]
|
||||
if fname == "Solid" and args:
|
||||
return {"color": _const(args[0])}
|
||||
if fname in ("Movie",):
|
||||
play = kw.get("play") or (args[0] if args else None)
|
||||
return {"movie": _const(play)} if play else None
|
||||
if fname == "ConditionSwitch":
|
||||
pairs = []
|
||||
for i in range(0, len(args) - 1, 2):
|
||||
cond = args[i]
|
||||
cexpr = (self.expr(cond["v"], line, const(False))
|
||||
if cond.get("k") == "const" and isinstance(cond["v"], str) else cond)
|
||||
sub = self.displayable_expr(args[i + 1], line)
|
||||
if sub:
|
||||
pairs.append([cexpr, sub])
|
||||
return {"switch": pairs}
|
||||
if fname in ("Composite", "LiveComposite", "im.Composite") and args:
|
||||
size = _const(args[0])
|
||||
layers = []
|
||||
for i in range(1, len(args) - 1, 2):
|
||||
pos = _const(args[i])
|
||||
sub = self.displayable_expr(args[i + 1], line)
|
||||
if sub and isinstance(pos, (list, tuple)):
|
||||
layers.append({"pos": list(pos), "img": sub})
|
||||
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
|
||||
else None}
|
||||
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
|
||||
for a in args:
|
||||
sub = self.displayable_expr(a, line) if a.get("k") in ("const", "call") else None
|
||||
if sub:
|
||||
props = _static_props(kw)
|
||||
if fname in ("im.Flip", "Flip") and (_const(kw.get("horizontal")) is True):
|
||||
props["xzoom"] = -1
|
||||
if fname in ("im.Scale", "im.FactorScale") and len(args) >= 2:
|
||||
if fname == "im.FactorScale":
|
||||
props["zoom"] = _const(args[1])
|
||||
elif len(args) >= 3:
|
||||
props["size"] = [_const(args[1]), _const(args[2])]
|
||||
if fname in ("im.Grayscale", "im.Sepia"):
|
||||
props["filter"] = "grayscale" if fname == "im.Grayscale" else "sepia"
|
||||
if props:
|
||||
sub = {**sub, "props": {**sub.get("props", {}), **props}}
|
||||
return sub
|
||||
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
|
||||
return None
|
||||
|
||||
def atl_image(self, line: Line) -> dict[str, Any] | None:
|
||||
frames: list[dict[str, Any]] = []
|
||||
props: dict[str, Any] = {}
|
||||
for child in line.block:
|
||||
c = self.cursor(child)
|
||||
s = c.string()
|
||||
if s is not None:
|
||||
img = self.displayable_expr(const(s), child)
|
||||
if img:
|
||||
frames.append(img)
|
||||
continue
|
||||
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
|
||||
|
||||
def st_transform(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("transform")
|
||||
name = c.word()
|
||||
props: dict[str, Any] = {}
|
||||
for child in line.block:
|
||||
props.update(_atl_props(child.text))
|
||||
if name:
|
||||
self.d.transforms[name] = props
|
||||
return []
|
||||
|
||||
# visual statements -------------------------------------------------------------
|
||||
|
||||
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
|
||||
if c.keyword("expression"):
|
||||
e = self.expr(c.until(_SHOW_KEYWORDS), line)
|
||||
return {"expr": e}
|
||||
parts: list[str] = []
|
||||
while True:
|
||||
save = c.pos
|
||||
p = c.image_part()
|
||||
if p is None:
|
||||
break
|
||||
if p in _SHOW_KEYWORDS:
|
||||
c.pos = save
|
||||
break
|
||||
parts.append(p)
|
||||
if not parts:
|
||||
raise c.error("ожидалось имя изображения")
|
||||
return {"name": parts}
|
||||
|
||||
def show_clauses(self, c: Cursor, line: Line, node: Node) -> None:
|
||||
node.setdefault("at", [])
|
||||
node.setdefault("with", None)
|
||||
while not c.eol():
|
||||
if c.keyword("as"):
|
||||
node["as"] = c.image_part()
|
||||
elif c.keyword("at"):
|
||||
for item in _split_top(c.until(_SHOW_KEYWORDS - {"at"}, ":")):
|
||||
node["at"].append(self.transform_ref(item, line))
|
||||
elif c.keyword("behind"):
|
||||
node["behind"] = [b.strip() for b in c.until(_SHOW_KEYWORDS, ":").split(",")]
|
||||
elif c.keyword("onlayer"):
|
||||
node["layer"] = c.word()
|
||||
elif c.keyword("zorder"):
|
||||
node["zorder"] = self.expr(c.until(_SHOW_KEYWORDS, ":"), line)
|
||||
elif c.keyword("with"):
|
||||
node["with"] = self.transition(c.until(set(), ":"), line)
|
||||
elif c.char(":"):
|
||||
props: dict[str, Any] = {}
|
||||
for child in line.block:
|
||||
props.update(_atl_props(child.text))
|
||||
if props:
|
||||
node["at"].append({"props": props})
|
||||
else:
|
||||
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
|
||||
break
|
||||
|
||||
def transform_ref(self, source: str, line: Line) -> dict[str, Any]:
|
||||
source = source.strip()
|
||||
if source in POSITIONS:
|
||||
return {"pos": source}
|
||||
if source in self.d.transforms:
|
||||
return {"props": self.d.transforms[source], "name": source}
|
||||
e = self.expr(source, line)
|
||||
if e and e["k"] == "call":
|
||||
fname = _dotted_name(e["f"])
|
||||
if fname in ("Position", "Transform"):
|
||||
return {"props": _static_props(e["kw"])}
|
||||
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:
|
||||
# transform defined later in another file; the player resolves it by name
|
||||
return {"name": e["id"]}
|
||||
self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}")
|
||||
return {"name": source}
|
||||
|
||||
def st_scene(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("scene")
|
||||
node: Node = {"op": "scene", "img": None}
|
||||
if c.keyword("onlayer"):
|
||||
node["layer"] = c.word()
|
||||
if not c.eol() and not c.peek("with") and not c.peek("at"):
|
||||
node["img"] = self.image_spec(c, line)
|
||||
self.show_clauses(c, line, node)
|
||||
self.d.stats["scene"] += 1
|
||||
return [node]
|
||||
|
||||
def st_show(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("show")
|
||||
if c.keyword("screen"):
|
||||
self.d.warn("Не поддерживается: screen", self.where(line))
|
||||
return []
|
||||
if c.keyword("text"):
|
||||
text = c.string() or ""
|
||||
node: Node = {"op": "show", "img": {"text": text}}
|
||||
self.show_clauses(c, line, node)
|
||||
return [node]
|
||||
node = {"op": "show", "img": self.image_spec(c, line)}
|
||||
self.show_clauses(c, line, node)
|
||||
self.d.stats["show"] += 1
|
||||
return [node]
|
||||
|
||||
def st_hide(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("hide")
|
||||
if c.keyword("screen"):
|
||||
return []
|
||||
spec = self.image_spec(c, line)
|
||||
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
|
||||
self.show_clauses(c, line, node)
|
||||
node.pop("at", None)
|
||||
return [node]
|
||||
|
||||
def st_with(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("with")
|
||||
return [{"op": "with", "t": self.transition(c.rest(), line)}]
|
||||
|
||||
def transition(self, source: str, line: Line) -> dict[str, Any] | None:
|
||||
source = source.strip()
|
||||
if source in ("None", ""):
|
||||
return None
|
||||
if source in self.d.transitions:
|
||||
return self.d.transitions[source]
|
||||
if source in TRANSITIONS:
|
||||
return TRANSITIONS[source]
|
||||
e = self.expr(source, line)
|
||||
return self.transition_expr(e) if e else TRANSITIONS["dissolve"]
|
||||
|
||||
def transition_expr(self, e: Expr) -> dict[str, Any] | None:
|
||||
if e["k"] == "const" and e["v"] is None:
|
||||
return None
|
||||
if e["k"] == "name":
|
||||
return self.d.transitions.get(e["id"]) or TRANSITIONS.get(e["id"]) or {
|
||||
"t": "ref", "name": e["id"]}
|
||||
if e["k"] == "call":
|
||||
fname = _dotted_name(e["f"])
|
||||
args = [_const(a) for a in e["a"]]
|
||||
kw = {k: _const(v) for k, v in e["kw"].items()}
|
||||
nums = [a for a in args if isinstance(a, (int, float))]
|
||||
if fname == "Fade":
|
||||
out = nums[0] if nums else 0.5
|
||||
hold = nums[1] if len(nums) > 2 else 0.0
|
||||
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 ("MoveTransition", "Move"):
|
||||
return {"t": "move", "d": nums[0] if nums else 0.5}
|
||||
if fname in TRANSITIONS:
|
||||
return TRANSITIONS[fname]
|
||||
return {"t": "dissolve", "d": nums[0] if nums else kw.get("time", 0.5) or 0.5}
|
||||
return TRANSITIONS["dissolve"]
|
||||
|
||||
# audio ------------------------------------------------------------------------
|
||||
|
||||
def audio_files(self, c: Cursor, line: Line) -> list[Expr]:
|
||||
src = c.until({"fadein", "fadeout", "loop", "noloop", "if_changed", "volume", "channel"})
|
||||
e = self.expr(src, line)
|
||||
if e is None:
|
||||
return []
|
||||
if e["k"] in ("list", "tuple"):
|
||||
return e["v"]
|
||||
return [e]
|
||||
|
||||
def st_play(self, line: Line, op: str = "play") -> list[Node]:
|
||||
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"):
|
||||
node["fadein"] = _num(c.number())
|
||||
elif c.keyword("fadeout"):
|
||||
node["fadeout"] = _num(c.number())
|
||||
elif c.keyword("loop"):
|
||||
node["loop"] = True
|
||||
elif c.keyword("noloop"):
|
||||
node["loop"] = False
|
||||
elif c.keyword("if_changed"):
|
||||
node["ifChanged"] = True
|
||||
elif c.keyword("volume"):
|
||||
node["volume"] = _num(c.number())
|
||||
else:
|
||||
c.rest()
|
||||
self.d.stats["audio"] += 1
|
||||
return [node]
|
||||
|
||||
def st_queue(self, line: Line) -> list[Node]:
|
||||
return self.st_play(line, "queue")
|
||||
|
||||
def st_stop(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("stop")
|
||||
node: Node = {"op": "stop", "ch": c.word() or "music"}
|
||||
if c.keyword("fadeout"):
|
||||
node["fadeout"] = _num(c.number())
|
||||
return [node]
|
||||
|
||||
def st_voice(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("voice")
|
||||
if c.keyword("sustain"):
|
||||
return [{"op": "voice", "sustain": True}]
|
||||
e = self.expr(c.rest(), line)
|
||||
return [{"op": "voice", "file": e}] if e else []
|
||||
|
||||
# flow ---------------------------------------------------------------------------
|
||||
|
||||
def st_jump(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("jump")
|
||||
if c.keyword("expression"):
|
||||
return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}]
|
||||
return [{"op": "jump", "target": c.label_name()}]
|
||||
|
||||
def st_call(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("call")
|
||||
if c.keyword("screen"):
|
||||
self.d.warn("Не поддерживается: call screen", self.where(line))
|
||||
return []
|
||||
if c.keyword("expression"):
|
||||
node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line,
|
||||
const(""))}
|
||||
else:
|
||||
node = {"op": "call", "target": c.label_name()}
|
||||
if c.peek("("):
|
||||
self.d.warn("Аргументы call не поддерживаются", self.where(line))
|
||||
return [node]
|
||||
|
||||
def st_return(self, line: Line) -> list[Node]:
|
||||
return [{"op": "return"}]
|
||||
|
||||
def st_pass(self, line: Line) -> list[Node]:
|
||||
return []
|
||||
|
||||
def st_pause(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("pause")
|
||||
rest = c.rest()
|
||||
return [{"op": "pause", "d": self.expr(rest, line) if rest else None}]
|
||||
|
||||
def st_while(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("while")
|
||||
cond = c.rest().removesuffix(":").strip()
|
||||
return [{"op": "while", "c": self.expr(cond, line, const(False)),
|
||||
"body": self.block(line.block)}]
|
||||
|
||||
def st_if(self, line: Line) -> list[Node]:
|
||||
branches, _ = self.if_chain([line], 0)
|
||||
return [{"op": "if", "branches": branches}]
|
||||
|
||||
def st_window(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("window")
|
||||
mode = c.word()
|
||||
return [{"op": "window", "mode": mode}] if mode in ("show", "hide") else []
|
||||
|
||||
def st_nvl(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("nvl")
|
||||
action = c.word()
|
||||
return [{"op": "nvl", "a": action}] if action in ("clear", "show", "hide") else []
|
||||
|
||||
def st_menu(self, line: Line) -> list[Node]:
|
||||
node: Node = {"op": "menu", "items": [], "say": None}
|
||||
for child in line.block:
|
||||
c = self.cursor(child)
|
||||
if c.keyword("set"):
|
||||
node["set"] = self.expr(c.rest(), child)
|
||||
continue
|
||||
text = c.string()
|
||||
if text is not None and child.text.rstrip().endswith(":"):
|
||||
cond = None
|
||||
if c.keyword("if"):
|
||||
cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True))
|
||||
elif c.peek("("):
|
||||
c.until({"if"}, ":")
|
||||
if c.keyword("if"):
|
||||
cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True))
|
||||
node["items"].append({"text": text, "if": cond, "body": self.block(child.block)})
|
||||
continue
|
||||
say = self.say(child)
|
||||
if say:
|
||||
node["say"] = say[0]
|
||||
else:
|
||||
self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}")
|
||||
self.d.stats["menu"] += 1
|
||||
return [node]
|
||||
|
||||
# say ------------------------------------------------------------------------------
|
||||
|
||||
def say(self, line: Line) -> list[Node] | None:
|
||||
c = self.cursor(line)
|
||||
who: str | None = None
|
||||
who_str: str | None = None
|
||||
attrs: list[str] = []
|
||||
first = c.string()
|
||||
if first is not None:
|
||||
second = c.string() if not c.eol() else None
|
||||
if second is not None:
|
||||
who_str, what = first, second
|
||||
else:
|
||||
what = first
|
||||
else:
|
||||
who = c.dotted()
|
||||
if who is None:
|
||||
return None
|
||||
while True:
|
||||
save = c.pos
|
||||
if c.string_raw() is not None:
|
||||
c.pos = save
|
||||
break
|
||||
p = c.image_part() or (("@" if c.char("@") else None))
|
||||
if p is None:
|
||||
c.pos = save
|
||||
break
|
||||
if p != "@":
|
||||
attrs.append(p)
|
||||
what = c.string()
|
||||
if what is None:
|
||||
return None
|
||||
node: Node = {"op": "say", "what": what}
|
||||
if who and who != "narrator":
|
||||
node["who"] = who
|
||||
if who_str is not None:
|
||||
node["whoStr"] = who_str
|
||||
if attrs:
|
||||
node["attrs"] = attrs
|
||||
while not c.eol():
|
||||
if c.keyword("with"):
|
||||
node["with"] = self.transition(c.rest(), line)
|
||||
elif c.keyword("nointeract"):
|
||||
node["interact"] = False
|
||||
elif c.keyword("id"):
|
||||
c.word()
|
||||
elif c.char("("):
|
||||
c.pos -= 1
|
||||
c.until({"with", "nointeract", "id"})
|
||||
else:
|
||||
c.rest()
|
||||
self.d.stats["say"] += 1
|
||||
return [node]
|
||||
|
||||
|
||||
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
||||
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
||||
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
|
||||
|
||||
|
||||
def _dedent(source: str) -> str:
|
||||
import textwrap
|
||||
return textwrap.dedent(source)
|
||||
|
||||
|
||||
def _dotted_name(e: Expr) -> str:
|
||||
if e["k"] == "name":
|
||||
return e["id"]
|
||||
if e["k"] == "attr":
|
||||
return f"{_dotted_name(e['o'])}.{e['a']}"
|
||||
return ""
|
||||
|
||||
|
||||
def _assign_name(t: ast.AST) -> str | None:
|
||||
if isinstance(t, ast.Name):
|
||||
return t.id
|
||||
if isinstance(t, ast.Attribute):
|
||||
inner = _assign_name(t.value)
|
||||
return f"{inner}.{t.attr}" if inner else None
|
||||
return None
|
||||
|
||||
|
||||
def _const(e: Expr | None) -> Any:
|
||||
if e is None:
|
||||
return None
|
||||
if e.get("k") == "const":
|
||||
return e["v"]
|
||||
if e.get("k") in ("tuple", "list"):
|
||||
return [_const(x) for x in e["v"]]
|
||||
if e.get("k") == "unary" and e["op"] == "-":
|
||||
v = _const(e["v"])
|
||||
return -v if isinstance(v, (int, float)) else None
|
||||
return None
|
||||
|
||||
|
||||
def _num(s: str | None) -> float | None:
|
||||
try:
|
||||
return float(s) if s is not None else None
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _static_props(kw: dict[str, Expr]) -> dict[str, Any]:
|
||||
props = {}
|
||||
for k, v in kw.items():
|
||||
if k in POSITION_PROPS:
|
||||
val = _const(v)
|
||||
if val is not None:
|
||||
props[k] = val
|
||||
return props
|
||||
|
||||
|
||||
def _atl_props(text: str) -> dict[str, Any]:
|
||||
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
|
||||
tokens = text.replace(",", " ").split()
|
||||
props: dict[str, Any] = {}
|
||||
i = 0
|
||||
while i < len(tokens) - 1:
|
||||
key = tokens[i]
|
||||
if key in POSITION_PROPS:
|
||||
raw = tokens[i + 1]
|
||||
try:
|
||||
props[key] = ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
if raw.startswith("(") and i + 2 < len(tokens):
|
||||
try:
|
||||
props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2]))
|
||||
i += 1
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
return props
|
||||
|
||||
|
||||
def _split_top(source: str) -> list[str]:
|
||||
parts, depth, cur = [], 0, []
|
||||
for ch in source:
|
||||
if ch in "([{":
|
||||
depth += 1
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
if ch == "," and depth == 0:
|
||||
parts.append("".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
if "".join(cur).strip():
|
||||
parts.append("".join(cur))
|
||||
return [p.strip() for p in parts if p.strip()]
|
||||
|
||||
|
||||
def _jump_or_call(op: str, target: Expr) -> Node:
|
||||
if target.get("k") == "const":
|
||||
return {"op": op, "target": target["v"]}
|
||||
return {"op": op, "expr": target}
|
||||
Reference in new issue
Block a user