"""Ren'Py ATL (animation and transformation language) → PGVN timeline. A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps: * ``{"op": "set", "props": {...}}`` – assign properties at once * ``{"op": "move", "warp": "linear|ease|easein|easeout", "t": seconds, "props": {...}}`` * ``{"op": "pause", "t": seconds}`` * ``{"op": "parallel", "blocks": [block, ...]}`` * ``{"op": "choice", "blocks": [{"w": weight, "block": block}, ...]}`` * ``{"op": "block", "block": block}`` * ``{"op": "on", "events": [...], "block": block}`` * ``{"op": "use", "name": transform}`` * ``{"op": "image", "img": ImageDef, "with": transition | None}`` – show another picture * ``{"op": "contains", "img": {"timeline": block}}`` – a child picture with its own timeline * ``{"op": "function", "f": expr}`` – call ``f(trans, st, at)`` every frame (Python decides the motion) * ``{"op": "time", "t": seconds}`` – wait until this much time has passed since the block began A number or property value that is not a literal becomes ``{"e": expr}`` and is evaluated when the timeline runs, with the transform's parameters in scope (``transform blink(delay, cycle):``). Everything else (``function``, ``contains``, ``time``, expressions, shaders, matrix colours) is reported through ``warn`` so the game is not imported half-working. """ import ast import re from collections.abc import Callable from typing import Any from app.converter import pyexpr from app.converter.exprtokens import read_value from app.converter.rpy_helpers import POSITION_PROPS from app.converter.rpy_lexer import Line Block = dict[str, Any] CALL_LINE = re.compile(r"^[A-Za-z_]\w*\(.*\)$", re.S) GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\))$", re.S) # Warpers differ only in easing shape; the player implements these four curves and # maps the named variants (easein_quad, ease_back, ...) onto the closest one. WARPER = re.compile(r"^(linear|ease|easein|easeout|easeinout)(_(quad|cubic|quart|quint|expo|circ|back|bounce|elastic|sine))?$") LEADING_ZERO = re.compile(r"(? list[str]: """Split on whitespace/commas outside brackets and quotes.""" words = _split(text) out: list[str] = [] for word in words: signed = SPLIT_SIGN.match(word) if signed and signed[1] in POSITION_PROPS: out.extend([signed[1], signed[2]]) continue glued = GLUED_NUMBER.match(word) if glued and not re.fullmatch(r"e\d+", glued[2]): out.extend([glued[1], glued[2]]) else: out.append(word) return out def _split(text: str) -> list[str]: out, cur, depth, quote = [], [], 0, "" for ch in text: if quote: cur.append(ch) if ch == quote: quote = "" elif ch in "\"'": quote = ch cur.append(ch) elif ch in "([{": depth += 1 cur.append(ch) elif ch in ")]}": depth -= 1 cur.append(ch) elif (ch.isspace() or ch == ",") and depth == 0: if cur: out.append("".join(cur)) cur = [] else: cur.append(ch) if cur: out.append("".join(cur)) return out class AtlParser: def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None], on_use: Callable[[str], None] = lambda name: None, image: Callable[[Line], tuple[dict, Any] | None] | None = None): self.warn = warn self.on_use = on_use # a line that is a picture ("a.png" with dissolve) -> (ImageDef, transition) | None self.image = image # name -> props when `name` is a known static transform (inlined), else None self.static_transform = is_static # --- helpers ------------------------------------------------------------------- def value(self, raw: str, line: Line) -> Any: """A literal, or ``{"e": expr}`` for anything computed at run time.""" try: return ast.literal_eval(raw) except (ValueError, SyntaxError): pass raw = LEADING_ZERO.sub(r"\1", raw) # `(0.5, 05)`: Ren'Py reads 05 as 5 try: return ast.literal_eval(raw) except (ValueError, SyntaxError): pass try: return {"e": pyexpr.parse_expr(raw)} except pyexpr.Unsupported: self.warn(f"Не поддерживается ATL: выражение {raw}", line) return None def number(self, raw: str, line: Line) -> Any: value = self.value(raw, line) if value is None: return None if isinstance(value, dict): return value if isinstance(value, bool) or not isinstance(value, (int, float)): self.warn(f"Не поддерживается ATL: значение {raw}", line) return None return float(value) def props(self, parts: list[str], line: Line) -> dict[str, Any]: out: dict[str, Any] = {} i = 0 while i < len(parts): key = parts[i] glued = GLUED.match(key) # decompiled `anchor(0.5, 0.5)` if glued and (glued[1] in POSITION_PROPS or glued[1] in IGNORED or glued[1].startswith(COSMETIC)): parts = [*parts[:i], glued[1], glued[2], *parts[i + 1:]] key = glued[1] if key in NAMED_POSITIONS: out.update(NAMED_POSITIONS[key]) i += 1 continue if key in IGNORED or key.startswith(COSMETIC): _, i = read_value(parts, i + 1) if i + 1 < len(parts) else ("", i + 1) continue if key not in POSITION_PROPS: self.warn(f"Не поддерживается ATL: свойство {key}", line) return out if i + 1 >= len(parts): self.warn(f"Не поддерживается ATL: у {key} нет значения", line) return out source, i = read_value(parts, i + 1) while i < len(parts) and re.match(r"^[+\-*/]\s*[\w.(]", parts[i]): # `xpos edge +250` source += " " + parts[i] i += 1 knots = [] while i + 1 < len(parts) and parts[i] == "knot": # spline: the value is the end, knots the controls knot, i = read_value(parts, i + 1) knots.append(self.value(knot, line)) value = self.value(source, line) if value is None or None in knots: return out if knots: out[key] = {"spline": [list(k) if isinstance(k, tuple) else k for k in knots] + [list(value) if isinstance(value, tuple) else value]} continue if key == "xycenter" and isinstance(value, (list, tuple)) and len(value) == 2: out["xcenter"], out["ycenter"] = value continue out[key] = list(value) if isinstance(value, tuple) else value return out # --- block ---------------------------------------------------------------------- def block(self, lines: list[Line]) -> Block: steps: list[dict[str, Any]] = [] repeat: Any = None i = 0 while i < len(lines): line = lines[i] parts = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip()) if not parts: i += 1 continue if split := SPLIT_SIGN.match(parts[0]): # decompiled `xzoom-1` if split[1] in POSITION_PROPS: parts = [split[1], split[2], *parts[1:]] first = parts[0] i += 1 picture = self.image(line) if self.image and not line.block else None if picture: steps.append({"op": "image", "img": picture[0], "with": picture[1]}) elif first == "repeat": repeat = True if len(parts) == 1 else self.number(parts[1], line) if repeat is not True and repeat is not None: repeat = repeat if isinstance(repeat, dict) else int(repeat) elif first == "pause": seconds = self.number(parts[1], line) if len(parts) > 1 else None if seconds is not None: steps.append({"op": "pause", "t": seconds}) elif first in ("parallel", "choice", "on", "block", "time", "contains", "function"): i = self.compound(first, parts, lines, i - 1, steps) elif WARPER.match(first): self.move(first, parts, line, steps) elif first in IGNORED: continue elif first in NAMED_POSITIONS: props = dict(NAMED_POSITIONS[first]) if len(parts) > 1: # `truecenter zoom 10.0` props.update(self.props(parts[1:], line)) steps.append({"op": "set", "props": props}) elif re.fullmatch(r"[A-Za-z_]\w*\[.+\]", first) and len(parts) == 1 and not line.block: seconds = self.number(first, line) # `delays[1]`: a number picked from a list if seconds is not None: steps.append({"op": "pause", "t": seconds}) elif first in POSITION_PROPS or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS): props = self.props(parts, line) if props: steps.append({"op": "set", "props": props}) elif len(parts) == 1 and (called := self.transform_call(first, line)): steps.append(called) elif len(parts) == 1 and re.fullmatch(r"[A-Za-z_]\w*", first): self.on_use(first) known = self.static_transform(first) # not a transform when the game defines a number of that name: then it is a pause steps.append({"op": "set", "props": known} if known is not None else {"op": "use", "name": first, "pause": {"k": "name", "id": first}}) elif re.match(r"^[\d.(]", first) and not line.block: # decompiled `pause`: a bare number or arithmetic line is a wait seconds = self.number(line.text.strip(), line) if seconds is not None: steps.append({"op": "pause", "t": seconds}) else: self.warn(f"Не поддерживается ATL: {first}", line) return {"steps": steps, "repeat": repeat} def transform_call(self, source: str, line: Line) -> dict[str, Any] | None: """`other_transform(args)` inside a transform: run it with these arguments.""" if not CALL_LINE.match(source) or source.split("(")[0] in POSITION_PROPS: return None try: call = pyexpr.parse_expr(source) except pyexpr.Unsupported: return None if call["k"] != "call" or call["f"]["k"] != "name": return None self.on_use(call["f"]["id"]) # `delay(0.5, 2)` may also be a function returning a number of seconds to wait return {"op": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"], "pause": call} def move(self, warp: str, parts: list[str], line: Line, steps: list) -> None: base = WARPER.match(warp).group(1) # type: ignore[union-attr] if len(parts) < 2: self.warn(f"Не поддерживается ATL: у {warp} нет длительности", line) return seconds = self.number(parts[1], line) props = self.props(parts[2:], line) for child in line.block: # `linear 1.0:` followed by indented property lines props.update(self.props(tokens(child.text), child)) if seconds is None or not props: return steps.append({"op": "move", "warp": "ease" if base == "easeinout" else base, "t": seconds, "props": props}) def compound(self, first: str, parts: list[str], lines: list[Line], index: int, steps: list) -> int: """Handle parallel/choice/on groups. Returns the index of the next unread line.""" line = lines[index] if first == "function": expr = self.value(" ".join(parts[1:]), line) if len(parts) > 1 else None if isinstance(expr, dict): steps.append({"op": "function", "f": expr["e"]}) else: self.warn("Не поддерживается ATL: function", line) return index + 1 if first == "time": seconds = self.number(parts[1], line) if len(parts) > 1 and not line.block else None if seconds is None: self.warn("Не поддерживается ATL: time", line) else: steps.append({"op": "time", "t": seconds}) return index + 1 if first == "contains": if len(parts) == 1 and line.block: steps.append({"op": "contains", "img": {"timeline": self.block(line.block)}}) else: self.warn("Не поддерживается ATL: contains", line) return index + 1 if first == "block": steps.append({"op": "block", "block": self.block(line.block)}) return index + 1 if first == "on": events = [e for e in parts[1:] if e] if not events or any(e not in EVENTS for e in events): self.warn(f"Не поддерживается ATL: событие {' '.join(events)}", line) steps.append({"op": "on", "events": events, "block": self.block(line.block)}) return index + 1 # consecutive `parallel:` / `choice:` clauses form one statement group = [] j = index while j < len(lines): current = lines[j] words = tokens(current.text.rstrip(":").strip()) if not words or words[0] != first: break weight: Any = 1.0 if first == "choice" and len(words) > 1: weight = self.number(words[1], current) or 1.0 group.append((weight, self.block(current.block))) j += 1 if first == "parallel": steps.append({"op": "parallel", "blocks": [b for _, b in group]}) else: steps.append({"op": "choice", "blocks": [{"w": w, "block": b} for w, b in group]}) return j def computed(value: Any) -> bool: if isinstance(value, dict): return "e" in value return isinstance(value, list) and any(computed(item) for item in value) def static_props(block: Block) -> dict[str, Any] | None: """Merged properties when the block only assigns, else None (it needs the timeline).""" if block["repeat"] is not None or any(step["op"] != "set" for step in block["steps"]): return None if any(computed(v) for step in block["steps"] for v in step["props"].values()): return None merged: dict[str, Any] = {} for step in block["steps"]: merged.update(step["props"]) return merged def initial_props(block: Block) -> dict[str, Any]: """Properties assigned before the first animated step (the state the element is created in).""" merged: dict[str, Any] = {} for step in block["steps"]: if step["op"] != "set": break merged.update({k: v for k, v in step["props"].items() if not computed(v)}) return merged