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