Files
pgames-manager/backend/app/converter/rpy_parser.py
T
mrleo1nidandClaude Opus 5.5 364b312064 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>
2026-10-25 02:41:32 +03:00

975 lines
40 KiB
Python

"""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}