Files
pgames-manager/backend/app/converter/atl.py
T
mrleo1nidandClaude Sonnet 5.5 5c1e1aee01 feat: ATL durations in brackets, offset and GPU hints; screen for-index, glued strings and onlayer
ATL: `ease(1.0) zoom 1.3`, `ease (0.1)xpos 0.5`, `ease(0.1)rotate 5` and `pause(1.5)` take the duration
in brackets; `offset (x, y)` becomes xoffset/yoffset; lines starting with shader, mesh, u_*, blend or
transform_anchor are skipped as GPU hints. thelewdesthouse drops from about 1400 to 111 rejections.
Screens: `for x index k in seq`, `add"x"` without a space, and `onlayer name` after show/call screen
(the layer is ignored, every screen lives in one layer). Transforms named with a capital letter and
declared in another file are resolved by the prescan, and `__("Name") "text"` is a translatable speaker.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 04:02:22 +03:00

433 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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))?$")
WARPER_CALL = re.compile(r"^(pause|(?:linear|ease|easein|easeout|easeinout)(?:_(?:quad|cubic|quart|quint|expo|circ|back|bounce|elastic|sine))?)\(")
LEADING_ZERO = re.compile(r"(?<![\w.])0+(\d)(?![\d.]*[\w.])")
SPLIT_SIGN = re.compile(r"^([a-z_]+)(-[\d.]+)$")
IGNORED = {"subpixel", "animation", "around", "alignaround", "rotate_pad"} # no visible effect in the browser
# Purely graphical GPU hints; dropping them changes how a picture is drawn, never what happens
COSMETIC = ("transform_anchor", "shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate",
"zrotate", "zpos", "zzoom", "delay", "events", "fit", "nearest", "fps", "show_cancels_hide")
NAMED_POSITIONS = {
"left": {"xpos": 0.0, "xanchor": 0.0}, "right": {"xpos": 1.0, "xanchor": 1.0},
"center": {"xpos": 0.5, "xanchor": 0.5}, "truecenter": {"xpos": 0.5, "xanchor": 0.5, "ypos": 0.5, "yanchor": 0.5},
"top": {"xpos": 0.5, "xanchor": 0.5, "ypos": 0.0, "yanchor": 0.0},
"topleft": {"xpos": 0.0, "xanchor": 0.0, "ypos": 0.0, "yanchor": 0.0},
"topright": {"xpos": 1.0, "xanchor": 1.0, "ypos": 0.0, "yanchor": 0.0},
}
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default", "hover", "idle",
"selected_hover", "selected_idle", "insensitive", "selected_insensitive", "selected",
# Ren'Py itself never sends these (its selected states are the selected_* ones): the handler is inert
"deselected", "unselected"}
GLUED_NUMBER = re.compile(r"^(\d+(?:\.\d*)?|\.\d+)([A-Za-z_]\w*)$")
def tokens(text: str) -> 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
#: properties that take two numbers; `pos 0.5, 0.5` stays one statement
TWO_VALUE = {"pos", "anchor", "align", "offset", "around", "size", "crop", "xysize", "xycenter", "alignaround"}
def statements(text: str) -> list[str]:
"""Split an ATL line at commas outside brackets and quotes: `zoom 1.2, 0.2` is `zoom 1.2` then a pause."""
pieces, cur, depth, quote = [], [], 0, ""
for ch in text:
if quote:
quote = "" if ch == quote else quote
elif ch in "\"'":
quote = ch
elif ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "," and depth == 0:
pieces.append("".join(cur).strip())
cur = []
continue
cur.append(ch)
pieces.append("".join(cur).strip())
merged: list[str] = []
for piece in pieces:
if not piece:
continue
previous = merged[-1].split() if merged else []
if len(previous) == 2 and previous[0] in TWO_VALUE and len(piece.split()) == 1:
merged[-1] = f"{merged[-1]} {piece}"
else:
merged.append(piece)
return merged
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 and key != "offset":
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
if key == "offset": # `offset (x, y)` is xoffset and yoffset together
if isinstance(value, (list, tuple)) and len(value) == 2:
out["xoffset"], out["yoffset"] = value
else:
self.warn("Не поддерживается ATL: offset не пара чисел", line)
continue
out[key] = list(value) if isinstance(value, tuple) else value
return out
# --- block ----------------------------------------------------------------------
@staticmethod
def split_bracket(token: str) -> list[str]:
"""`(0.1)xpos` -> `(0.1)`, `xpos`: a bracketed duration with the property glued to it."""
depth = 0
for at, ch in enumerate(token):
depth += (ch == "(") - (ch == ")")
if depth == 0:
return [token[:at + 1], token[at + 1:]] if token[at + 1:] else [token]
return [token]
def expand(self, lines: list[Line]) -> list[Line]:
"""Lines holding several comma-separated statements become one line per statement."""
out: list[Line] = []
for line in lines:
pieces = statements(line.text.strip()) if not line.block and "," in line.text else []
if len(pieces) > 1:
out.extend(Line(piece, line.lineno, line.indent, line.physical) for piece in pieces)
else:
out.append(line)
return out
def block(self, lines: list[Line]) -> Block:
lines = self.expand(lines)
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:]]
if called := WARPER_CALL.match(parts[0]): # `easein(1.0) zoom 1.3`: the duration in brackets
depth = 0
for at, ch in enumerate(parts[0][called.end() - 1:], called.end() - 1):
depth += (ch == "(") - (ch == ")")
if depth == 0:
break
glued = [parts[0][at + 1:]] if parts[0][at + 1:] else [] # `ease(0.1)rotate 5`
parts = [called[1], parts[0][called.end():at], *glued, *parts[1:]]
if len(parts) > 1 and (WARPER.match(parts[0]) or parts[0] == "pause") and parts[1].startswith("("):
parts = [parts[0], *self.split_bracket(parts[1]), *parts[2:]] # `ease (0.1)xpos 0.5`
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 or first.startswith(COSMETIC):
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