Files
pgames-manager/backend/app/converter/atl.py
T
mrleo1nidandClaude Sonnet 5.5 ff82d882c0 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>
2026-10-25 12:11:07 +03:00

212 lines
8.7 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}``
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