feat: add ATL animation timelines and try/raise to the Ren'Py player

Python sandbox
- try/except/else/finally, raise (incl. re-raise), assert, for/while ... else
- Multi-generator and dict comprehensions
- Builtin exception hierarchy; user exceptions subclass it
- Inside try, missing keys/indexes, division by zero, attribute access on None
  and unknown names raise Python exceptions; lookups stay lenient elsewhere
- int()/float() raise ValueError on non-numeric strings

ATL
- converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat,
  parallel, choice, block, on show/hide and named transforms become a timeline
  shipped as game.json "atl"; Move() becomes a linear slide
- player/atl.ts: timeline runner wired into the renderer; `on hide` plays
  before the picture is removed
- function, time, contains, matrixcolor and expressions still block the import

Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README
and ARCHITECTURE updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 12:11:07 +03:00
1 parent 962595c925
commit ff82d882c0
18 files changed
+1050 -53

No files matched your search

+211
View File
@@ -0,0 +1,211 @@
"""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}``
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.rpy_helpers import POSITION_PROPS
from app.converter.rpy_lexer import Line
Block = dict[str, Any]
# 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))?$")
IGNORED = {"subpixel", "animation"} # no visible effect in the browser
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default"}
def tokens(text: str) -> list[str]:
"""Split on whitespace/commas outside brackets and quotes."""
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]):
self.warn = warn
# name -> props when `name` is a known static transform (inlined), else None
self.static_transform = is_static
# --- helpers -------------------------------------------------------------------
def number(self, raw: str, line: Line) -> float | None:
try:
value = ast.literal_eval(raw)
except (ValueError, SyntaxError):
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
return None
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]
if key in IGNORED:
i += 2
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
raw = parts[i + 1]
try:
value = ast.literal_eval(raw)
except (ValueError, SyntaxError):
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
return out
if isinstance(value, tuple):
value = list(value)
out[key] = value
i += 2
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
first = parts[0]
i += 1
if 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 = 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 POSITION_PROPS:
props = self.props(parts, line)
if props:
steps.append({"op": "set", "props": props})
elif len(parts) == 1 and re.fullmatch(r"[A-Za-z_]\w*", first):
known = self.static_transform(first)
steps.append({"op": "set", "props": known} if known is not None else {"op": "use", "name": first})
else:
self.warn(f"Не поддерживается ATL: {first}", line)
return {"steps": steps, "repeat": repeat}
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 in ("time", "contains", "function"):
self.warn(f"Не поддерживается ATL: {first}", 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 = 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 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
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(step["props"])
return merged
+4
View File
@@ -141,6 +141,10 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
names.update(p["n"] for p in value["params"])
elif kind == "comp":
target(value.get("t"))
for gen in value.get("gens", []):
target(gen.get("t"))
elif op == "try":
names.update(h["name"] for h in value.get("handlers", []) if h.get("name"))
for key, child in value.items():
visit(child, op == "class" and key == "body")
+1
View File
@@ -190,6 +190,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"characters": data.characters,
"images": images,
"transforms": data.transforms,
"atl": data.atl,
"transitions": data.transitions,
"audio": audio,
"channels": data.channels,
+29 -12
View File
@@ -120,14 +120,11 @@ def expr(node: ast.AST) -> Expr:
elif isinstance(v, ast.FormattedValue):
parts.append(expr(v.value))
return {"k": "fmt", "v": parts}
case (ast.ListComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])
| ast.GeneratorExp(elt=elt, generators=[ast.comprehension(target=t, iter=it,
ifs=ifs, is_async=0)])
| ast.SetComp(elt=elt, generators=[ast.comprehension(target=t, iter=it, ifs=ifs,
is_async=0)])):
return {"k": "comp", "e": expr(elt), "t": target(t), "it": expr(it),
"if": [expr(i) for i in ifs]}
case ast.ListComp(elt=elt, generators=gens) | ast.GeneratorExp(elt=elt, generators=gens) \
| ast.SetComp(elt=elt, generators=gens):
return {"k": "comp", "e": expr(elt), "gens": generators(gens)}
case ast.DictComp(key=key, value=value, generators=gens):
return {"k": "comp", "e": expr(key), "v": expr(value), "gens": generators(gens)}
case ast.Lambda(args=args, body=body):
return {"k": "lambda", "params": params(args), "body": expr(body)}
raise Unsupported(f"выражение {type(node).__name__}")
@@ -170,10 +167,18 @@ def stmt(node: ast.stmt) -> list[Stmt]:
return [{"op": "eval", "e": expr(value)}]
case ast.If(test=test, body=body, orelse=orelse):
return [{"op": "if", "c": expr(test), "then": stmts(body), "else": stmts(orelse)}]
case ast.While(test=test, body=body, orelse=[]):
return [{"op": "while", "c": expr(test), "body": stmts(body)}]
case ast.For(target=t, iter=it, body=body, orelse=[]):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body)}]
case ast.While(test=test, body=body, orelse=orelse):
return [{"op": "while", "c": expr(test), "body": stmts(body), "else": stmts(orelse)}]
case ast.For(target=t, iter=it, body=body, orelse=orelse):
return [{"op": "for", "t": target(t), "it": expr(it), "body": stmts(body),
"else": stmts(orelse)}]
case ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=final):
return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers],
"else": stmts(orelse), "final": stmts(final)}]
case ast.Raise(exc=exc):
return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped
case ast.Assert(test=test, msg=msg):
return [{"op": "assert", "c": expr(test), "m": None if msg is None else expr(msg)}]
case ast.Delete(targets=targets):
return [{"op": "del", "t": target(t)} for t in targets]
case ast.Pass():
@@ -248,3 +253,15 @@ def class_body(name: str, body: list[ast.stmt]) -> list[Stmt]:
and n.name not in SUPPORTED_DUNDERS):
raise Unsupported(f"специальный метод {name}.{n.name}()")
return stmts(body)
def generators(gens: list[ast.comprehension]) -> list[dict[str, Any]]:
if any(g.is_async for g in gens):
raise Unsupported("async comprehension")
return [{"t": target(g.target), "it": expr(g.iter), "if": [expr(i) for i in g.ifs]} for g in gens]
def handler(node: ast.ExceptHandler) -> dict[str, Any]:
"""``types`` is None for a bare ``except:``; ``name`` binds the exception."""
return {"types": None if node.type is None else expr(node.type), "name": node.name,
"body": stmts(node.body)}
+48 -9
View File
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
from typing import Any
from app.converter import pyexpr
from app.converter.atl import AtlParser, initial_props, static_props
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
from app.converter.rpy_helpers import (
_CALL_LINE,
@@ -67,6 +68,7 @@ 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)
atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name
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)
@@ -776,13 +778,24 @@ class RpyParser:
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:
props, animation = self.atl_spec(line.block)
self.d.transforms[name] = props
if animation:
self.d.atl[name] = animation
return []
def atl_spec(self, lines: list[Line]) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""Parse an ATL block: (properties to start with, timeline or None when it is static)."""
parser = AtlParser(lambda message, line: self.d.warn(message, self.where(line)),
lambda name: self.d.transforms.get(name) if name not in self.d.atl
and name in self.d.transforms else None)
block = parser.block(lines)
static = static_props(block)
if static is not None:
return static, None
return initial_props(block), block
def static_atl(self, line: Line) -> dict[str, Any]:
props = _atl_props(line.text)
first = Cursor(line.text).peek_word()
@@ -828,10 +841,10 @@ class RpyParser:
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:
props, animation = self.atl_spec(line.block)
if animation:
node["at"].append({"props": props, "atl": animation})
elif props:
node["at"].append({"props": props})
else:
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
@@ -842,24 +855,50 @@ class RpyParser:
if source in POSITIONS:
return {"pos": source}
if source in self.d.transforms:
return {"props": self.d.transforms[source], "name": source}
spec = {"props": self.d.transforms[source], "name": source}
if source in self.d.atl:
spec["atl"] = source
return spec
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 == "Move":
moved = self.move_transform(e)
if moved:
return moved
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}
spec = {"props": self.d.transforms[fname], "name": fname}
if fname in self.d.atl:
spec["atl"] = fname
return spec
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 move_transform(self, call: Expr) -> dict[str, Any] | None:
"""``Move((x0, y0), (x1, y1), seconds)``: slide the picture between two positions."""
args = [_const(a) for a in call["a"]]
kw = {k: _const(v) for k, v in call["kw"].items()}
start, end, seconds = (args + [None] * 3)[:3]
start, end = start or kw.get("pos1"), end or kw.get("pos2")
seconds = seconds if seconds is not None else kw.get("delay")
if not (isinstance(start, (list, tuple)) and isinstance(end, (list, tuple))
and len(start) == len(end) == 2 and isinstance(seconds, (int, float))):
return None
block = {"steps": [
{"op": "set", "props": {"xpos": start[0], "ypos": start[1]}},
{"op": "move", "warp": "linear", "t": float(seconds), "props": {"xpos": end[0], "ypos": end[1]}},
], "repeat": None}
return {"props": {"xpos": start[0], "ypos": start[1]}, "atl": block}
def st_scene(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("scene")
+38 -5
View File
@@ -85,10 +85,24 @@ def expression(value, depth=0):
for item in value["kw"].values():
expression(item, depth + 1)
elif kind == "comp":
for key in ("e", "t", "it"):
ex(key)
for item in seq("if"):
expression(item, depth + 1)
ex("e")
ex("v", True)
if "gens" in value:
gens = seq("gens")
if not gens or len(gens) > 8:
raise ValueError("Некорректный генератор")
for gen in gens:
if not isinstance(gen, dict) or not isinstance(gen.get("if"), list):
raise ValueError("Некорректный генератор")
expression(gen.get("t"), depth + 1)
expression(gen.get("it"), depth + 1)
for item in gen["if"]:
expression(item, depth + 1)
else: # saved by an older converter
for key in ("t", "it"):
ex(key)
for item in seq("if"):
expression(item, depth + 1)
else:
raise ValueError(f"Неизвестное выражение: {kind}")
@@ -113,7 +127,8 @@ def statements(code, depth=0):
keys = {"set": ("t", "v"), "aug": ("t", "v"), "eval": ("e",),
"if": ("c",), "while": ("c",), "for": ("t", "it"),
"del": ("t",), "break": (), "continue": (), "def": (), "class": (),
"return": (), "global": (), "nonlocal": ()}
"return": (), "global": (), "nonlocal": (), "try": (), "raise": (),
"assert": ("c",)}
if op not in keys:
raise ValueError(f"Неизвестный оператор AST: {op}")
for key in keys[op]:
@@ -123,6 +138,24 @@ def statements(code, depth=0):
statements(s.get("else"), depth + 1)
if op in ("while", "for"):
statements(s.get("body"), depth + 1)
statements(s.get("else", []), depth + 1)
if op == "try":
statements(s.get("body"), depth + 1)
statements(s.get("else", []), depth + 1)
statements(s.get("final", []), depth + 1)
handlers = s.get("handlers")
if not isinstance(handlers, list) or len(handlers) > 32:
raise ValueError("Некорректные обработчики исключений")
for h in handlers:
if not isinstance(h, dict) or not (h.get("name") is None or isinstance(h.get("name"), str)):
raise ValueError("Некорректный обработчик исключения")
if h.get("types") is not None:
expression(h["types"], depth + 1)
statements(h.get("body"), depth + 1)
if op == "raise" and s.get("v") is not None:
expression(s["v"], depth + 1)
if op == "assert" and s.get("m") is not None:
expression(s["m"], depth + 1)
if op == "def":
if not isinstance(s.get("name"), str):
raise ValueError("У функции нет имени")
+31 -4
View File
@@ -89,10 +89,37 @@ def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
require_supported_scripts(data)
def test_renpy_atl_animation_is_rejected_instead_of_becoming_static():
data = script('transform moving:\n linear 1.0 xalign 1.0\nlabel start:\n "Hi"')
with pytest.raises(CompatibilityError, match="ATL"):
require_supported_scripts(data)
def test_renpy_atl_animation_becomes_a_timeline_not_a_static_pose():
data = script('transform moving:\n xalign 0.0\n linear 1.0 xalign 1.0\n repeat\nlabel start:\n "Hi"')
require_supported_scripts(data)
assert data.transforms["moving"] == {"xalign": 0.0}
block = data.atl["moving"]
assert block["repeat"] is True
assert [step["op"] for step in block["steps"]] == ["set", "move"]
assert block["steps"][1]["props"] == {"xalign": 1.0} and block["steps"][1]["warp"] == "linear"
def test_renpy_atl_constructs_without_a_runtime_are_still_rejected():
for body in ("function slide", "time 1.0:\n xalign 1.0", "matrixcolor TintMatrix('#f00')",
"contains:\n 'a.png'"):
data = script(f'transform odd:\n {body}\nlabel start:\n "Hi"')
with pytest.raises(CompatibilityError, match="ATL"):
require_supported_scripts(data)
def test_renpy_atl_groups_events_and_named_transforms():
data = script(
"transform base:\n zoom 0.8\n"
"transform bob:\n base\n parallel:\n ease 1.0 yoffset -10\n ease 1.0 yoffset 0\n"
" repeat\n parallel:\n pause 0.5\n"
" on hide:\n linear 0.3 alpha 0.0\n"
'label start:\n "Hi"')
require_supported_scripts(data)
steps = data.atl["bob"]["steps"]
assert steps[0] == {"op": "set", "props": {"zoom": 0.8}} # known static transform is inlined
assert [s["op"] for s in steps] == ["set", "parallel", "on"]
assert len(steps[1]["blocks"]) == 2 and steps[1]["blocks"][0]["repeat"] is True
assert steps[2]["events"] == ["hide"]
def test_source_encoding_is_preserved_or_explicitly_rejected(tmp_path):