feat: ATL pictures/contains/function/time, colour matrices and more Python for Ren'Py

ATL
- Image blocks become timelines: picture steps with dissolve, parallel, contains,
  finite repeat, time, function f(trans, st, at), calls of other transforms with
  arguments, on hover/idle events for buttons. Animations stop when their element
  leaves the page
- Pictures defined by an expression (get_face_path("base")) are drawn at show time
- Purely graphical hints (shader, mesh, gl_*, u_*) are accepted and dropped

Colour
- matrixcolor and im.matrix.* as 5x5 matrices combined with `*`, applied through SVG
  filters; im.MatrixColor/Scale/Crop/Blur/Composite and Text/Image as values

Python
- match statement, re/copy/time/datetime/defaultdict/functools.partial, more
  str/dict methods, Character(...) created by code
- narrator("..."), Character calls, renpy.say and renpy.show/hide with expressions in
  Python blocks become script nodes
- Audit follows reachability: functions and classes nothing refers to (custom
  statement lexers, unused tools) no longer block an import

Docs: README and ARCHITECTURE now describe a multi-engine library (GameMaker and
QSP planned). Tests for the new runtime pieces.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:05:14 +03:00
1 parent 62ece25902
commit ea03cebf6a
21 files changed
+1264 -93

No files matched your search

+65 -10
View File
@@ -10,6 +10,10 @@ A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps:
* ``{"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):``).
@@ -23,16 +27,22 @@ 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]
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))?$")
IGNORED = {"subpixel", "animation"} # no visible effect in the browser
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default"}
# Purely graphical GPU hints; dropping them changes how a picture is drawn, never what happens
COSMETIC = ("shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate",
"zrotate", "zpos", "zzoom", "delay", "events", "fit", "nearest", "fps", "show_cancels_hide")
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default", "hover", "idle",
"selected_hover", "selected_idle", "insensitive", "selected_insensitive", "selected"}
def tokens(text: str) -> list[str]:
@@ -65,9 +75,12 @@ def tokens(text: str) -> list[str]:
class AtlParser:
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None],
on_use: Callable[[str], None] = lambda name: 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
@@ -101,8 +114,12 @@ class AtlParser:
i = 0
while i < len(parts):
key = parts[i]
if key in IGNORED:
i += 2
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 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)
@@ -110,11 +127,14 @@ class AtlParser:
if i + 1 >= len(parts):
self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
return out
value = self.value(parts[i + 1], line)
source, i = read_value(parts, i + 1)
value = self.value(source, line)
if value is None:
return out
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
i += 2
return out
# --- block ----------------------------------------------------------------------
@@ -131,7 +151,10 @@ class AtlParser:
continue
first = parts[0]
i += 1
if first == "repeat":
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)
@@ -145,10 +168,12 @@ class AtlParser:
self.move(first, parts, line, steps)
elif first in IGNORED:
continue
elif first in POSITION_PROPS:
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)
@@ -157,6 +182,19 @@ class AtlParser:
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 GLUED.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"])
return {"op": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"]}
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:
@@ -173,8 +211,25 @@ class AtlParser:
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)
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)})