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

+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")