"""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_helpers import ( _CALL_LINE, _DISPLAYABLE_CALLS, _MEDIA_EXTS, _NUMBER_LINE, _SHOW_KEYWORDS, POSITION_PROPS, _assign_name, _atl_props, _const, _dedent, _dotted_name, _jump_or_call, _num, _parse_py, _split_top, _static_props, ) 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"} CHANNELS = {"music", "sound", "voice", "audio", "movie"} SKIP_BLOCKS = {"screen", "style", "translate", "testcase"} 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) channels: dict[str, dict[str, Any]] = field(default_factory=dict) config: dict[str, Any] = field(default_factory=dict) modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels warnings: Counter = field(default_factory=Counter) blockers: Counter = field(default_factory=Counter) blocker_examples: dict[str, str] = field(default_factory=dict) warning_examples: dict[str, str] = field(default_factory=dict) stats: Counter = field(default_factory=Counter) def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None: self.warnings[kind] += 1 if blocking: self.blockers[kind] += 1 if where: self.blocker_examples.setdefault(kind, where) 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 self.global_label = "" def label_ref(self, name: str) -> str: """Resolve `.local` label names against the enclosing global label.""" return self.global_label + name if name.startswith(".") else name # --- helpers ------------------------------------------------------------- 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 = _parse_py(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": name, params = self.label_header(line) if not name.startswith("."): self.global_label = name current = {"name": self.label_ref(name), "body": self.block(line.block)} if params: current["params"] = params labels.append(current) continue nodes = self.statement(line, top_level=True) 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": # a label inside a block is an entry point in the middle of the flow: # keep a marker the player can jump to and inline the label's block name, params = self.label_header(line) if not name.startswith("."): self.global_label = name marker: Node = {"op": "label", "name": self.label_ref(name)} if params: marker["params"] = params out.append(marker) out.extend(self.block(line.block)) i += 1 continue out.extend(self.statement(line)) i += 1 return out def label_header(self, line: Line) -> tuple[str, list[list[Any]] | None]: c = self.cursor(line) c.keyword("label") name = c.label_name() if not name: raise c.error("ожидалось имя метки") params = None if c.peek("("): sig = c.until(set(), ":") try: fn = _parse_py(f"def _f{sig}: pass").body[0] args = fn.args defaults = [None] * (len(args.args) - len(args.defaults)) + list(args.defaults) params = [[a.arg, pyexpr.expr(d) if d is not None else None] for a, d in zip(args.args, defaults)] except (SyntaxError, Unsupported): self.d.warn("Не разобраны параметры метки", self.where(line)) return name, params def call_args(self, c: Cursor, line: Line) -> dict[str, Any] | None: if not c.peek("("): return None src = c.until({"from"}) try: call = _parse_py(f"_f{src}", mode="eval").body return {"a": [pyexpr.expr(a) for a in call.args], "kw": {k.arg: pyexpr.expr(k.value) for k in call.keywords}} except (SyntaxError, Unsupported, AttributeError): self.d.warn("Не разобраны аргументы call", self.where(line)) return None def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]: branches: list[Node] = [] while i < len(lines): 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 as e: # one odd statement (custom syntax, typo) must not drop the whole file self.d.warn(f"Не разобран оператор {word}", f"{e.filename}:{e.lineno}") return [] if word in SKIP_BLOCKS: if word not in ("style", "translate"): self.d.warn(f"Не поддерживается: {word}", self.where(line), blocking=not (top_level and word == "screen")) 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 = _parse_py(source) except SyntaxError as e: self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line)) return for node in tree.body: if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) and ast.unparse(node.value.func) == "renpy.music.register_channel"): self.register_channel(node.value) continue if isinstance(node, ast.Assign) and len(node.targets) == 1: tgt = node.targets[0] name = _assign_name(tgt) 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}") def register_channel(self, call: ast.Call) -> None: args = list(call.args) + [None] * 3 kw = {k.arg: k.value for k in call.keywords} def lit(node, default=None): try: return ast.literal_eval(node) if node is not None else default except ValueError: return default name = lit(args[0]) if isinstance(name, str): self.d.channels[name] = {"mixer": lit(kw.get("mixer", args[1]), "sfx"), "loop": bool(lit(kw.get("loop", args[2]), False))} # define / default / image / transform ------------------------------------------- def st_define(self, line: Line) -> list[Node]: 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") c.number() # optional init priority 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] = {} repeat = self.atl_frames(line.block, frames, props) if not frames: self.d.warn("ATL-изображение без кадров", self.where(line)) return None if len(frames) == 1: img = dict(frames[0]["img"]) else: img = {"frames": frames, "repeat": repeat} if props: img["props"] = {**img.get("props", {}), **props} return img def atl_frames(self, lines: list[Line], frames: list[dict[str, Any]], props: dict[str, Any]) -> bool: """Collect displayable frames of an ATL block. Returns whether it repeats.""" repeat = False for child in lines: c = self.cursor(child) word = c.peek_word() if word in ("contains", "block", "parallel", "choice"): if word != "block": self.d.warn(f"Не поддерживается ATL: {word}", self.where(child)) if not frames or word == "block": repeat = self.atl_frames(child.block, frames, props) or repeat continue if word == "repeat": if child.text.strip() != "repeat": self.d.warn("Не поддерживается ATL: конечное число повторов", self.where(child)) repeat = True continue if word in ("pause",) or _NUMBER_LINE.match(child.text): c.keyword("pause") d = _num(c.number()) if frames and d is not None: frames[-1]["d"] = frames[-1].get("d", 0) + d continue if word in ("function", "on", "event", "time", "transform"): self.d.warn(f"Не поддерживается ATL: {word}", self.where(child)) continue s = c.string() if s is not None: img = self.displayable_expr(const(s), child) if img: frames.append({"img": img}) continue m = _CALL_LINE.match(child.text) if m and m.group(1) in _DISPLAYABLE_CALLS: e = self.expr(child.text.split(" with ")[0], child) img = self.displayable_expr(e, child) if e else None if img: frames.append({"img": img}) continue if m and not frames and not _atl_props(child.text): # e.g. get_face_path("base"): an image computed by game code e = self.expr(child.text, child) if e is not None: self.d.warn("Изображение задано выражением", self.where(child)) frames.append({"img": {"expr": e}}) continue if not frames or not frames[-1].get("d"): props.update(self.static_atl(child)) return repeat def st_layeredimage(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("layeredimage") parts: list[str] = [] while (p := c.image_part()) is not None: parts.append(p) name = " ".join(parts) layers: list[dict[str, Any]] = [] at: list[dict[str, Any]] = [] for child in line.block: cc = self.cursor(child) kw = cc.word() if kw == "always": img = self.layer_image(cc, child, None) if img: layers.append({"always": img}) elif kw == "attribute": layers.append(self.layer_attribute(cc, child, name, None)) elif kw == "group": group = cc.word() or "" auto = False while not cc.eol(): w = cc.word() if w == "auto": auto = True elif w in ("multiple", "prefix", "variant"): cc.word() if w in ("prefix", "variant") else None elif w is None: break for sub in child.block: sc = self.cursor(sub) if sc.keyword("attribute"): layers.append(self.layer_attribute(sc, sub, name, group)) if auto: layers.append({"autoGroup": group}) elif kw == "if": cond = self.expr(cc.rest().removesuffix(":"), child, const(False)) img = self.layer_image(Cursor(""), child, None) if img: layers.append({"cond": [[cond, img]]}) elif kw in ("elif", "else"): img = self.layer_image(Cursor(""), child, None) if img and layers and "cond" in layers[-1]: cond = None if kw == "elif": cond = self.expr(cc.rest().removesuffix(":"), child, const(False)) layers[-1]["cond"].append([cond, img]) elif kw == "at": for item in _split_top(cc.rest()): at.append(self.transform_ref(item, child)) self.d.images[name] = {"layered": layers, "at": at} self.d.stats["layeredimage"] += 1 return [] def layer_attribute(self, c: Cursor, line: Line, image: str, group: str | None ) -> dict[str, Any]: attr = c.image_part() or "" layer: dict[str, Any] = {"attr": attr, "group": group} img = None while not c.eol(): if c.peek('"') or c.peek("'"): img = self.displayable_expr(const(c.string()), line) continue w = c.word() if w == "default": layer["default"] = True elif w == "null": layer["null"] = True elif w in ("if_any", "if_all", "if_not"): src = c.until({"if_any", "if_all", "if_not", "default", "null", "variant", "at"}, ":") try: val = pyexpr.literal(src) layer[w] = [val] if isinstance(val, str) else list(val) except Unsupported: pass elif w in ("variant", "at"): c.until({"if_any", "if_all", "if_not", "default", "null"}, ":") elif w is None: break if img is None and line.block: img = self.layer_image(Cursor(""), line, None) if img is None and not layer.get("null"): # Ren'Py's default naming: __ img = {"ref": "_".join(x for x in (image.replace(" ", "_"), group, attr) if x)} if img: layer["img"] = img return layer def layer_image(self, c: Cursor, line: Line, default) -> dict[str, Any] | None: s = c.string() if c.text else None if s is not None: return self.displayable_expr(const(s), line) for child in line.block: cc = self.cursor(child) s = cc.string() if s is not None: return self.displayable_expr(const(s), child) m = _CALL_LINE.match(child.text) if m and m.group(1) in _DISPLAYABLE_CALLS: e = self.expr(child.text, child) return self.displayable_expr(e, child) if e else None return default def st_camera(self, line: Line) -> list[Node]: c = self.cursor(line) c.keyword("camera") c.word() if not c.peek("at") and not c.peek(":") else None node: Node = {"op": "camera", "at": []} if c.keyword("at"): for item in _split_top(c.until(set(), ":")): node["at"].append(self.transform_ref(item, line)) props: dict[str, Any] = {} for child in line.block: props.update(_atl_props(child.text)) if props: node["at"].append({"props": props}) return [node] 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(self.static_atl(child)) if name: self.d.transforms[name] = props return [] def static_atl(self, line: Line) -> dict[str, Any]: props = _atl_props(line.text) first = Cursor(line.text).peek_word() if first not in POSITION_PROPS or not props or line.block: self.d.warn(f"Не поддерживается динамическая ATL-трансформация: {first or line.text}", self.where(line)) return props # 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(self.static_atl(child)) 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 ("Shake", "SnoopyShake"): args = [_const(a) for a in e["a"]] return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5, "dist": _const(e["kw"].get("dist")) or 20} if fname in self.d.transforms: return {"props": self.d.transforms[fname], "name": fname} if e and e["k"] == "name" and e["id"] not in self.d.transforms: # 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() if text is not None: node: Node = {"op": "show", "img": {"text": text}} else: node = {"op": "show", "img": {"textExpr": self.expr(c.until(_SHOW_KEYWORDS), line, const(""))}} self.show_clauses(c, line, node) return [node] node = {"op": "show", "img": self.image_spec(c, line)} 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 ("Shake", "SnoopyShake"): return {"t": "punch", "dir": "h", "d": nums[0] if nums else 0.5} if fname in ("MoveTransition", "Move"): return {"t": "move", "d": nums[0] if nums else 0.5} if fname in TRANSITIONS: 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" 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": self.label_ref(c.label_name() or "")}] 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(""))} c.keyword("pass") else: node = {"op": "call", "target": self.label_ref(c.label_name() or "")} args = self.call_args(c, line) if args: node["args"] = args return [node] def st_return(self, line: Line) -> list[Node]: 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} head = self.cursor(line) head.keyword("menu") name = head.label_name() out: list[Node] = [] if name: out.append({"op": "label", "name": self.label_ref(name)}) for child in line.block: c = self.cursor(child) if c.keyword("set"): node["set"] = self.expr(c.rest(), child) continue if c.keyword("with"): continue text = c.string() if text is not None and child.text.rstrip().endswith(":"): cond = None 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 out + [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, "_ln": line.lineno} # _ln: dropped after IDs 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]