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

+150 -14
View File
@@ -297,12 +297,25 @@ class RpyParser:
return c.string_raw() is not None or c.peek('"') or c.peek("'")
def dollar(self, code: str, line: Line) -> list[Node]:
stmts = self.python(code, line)
if len(stmts) == 1 and stmts[0]["op"] == "eval":
special = self.renpy_call(stmts[0]["e"], line)
if special is not None:
return special
return [{"op": "exec", "code": stmts}] if stmts else []
return self.python_nodes(self.python(code, line), line)
def python_nodes(self, stmts: list[Stmt], line: Line) -> list[Node]:
"""Statements become one `exec` node, except calls that are really script commands
(renpy.show, renpy.say, narrator("...")): those become the matching nodes."""
nodes: list[Node] = []
pending: list[Stmt] = []
for statement in stmts:
special = self.renpy_call(statement["e"], line) if statement.get("op") == "eval" else None
if special is None:
pending.append(statement)
continue
if pending:
nodes.append({"op": "exec", "code": pending})
pending = []
nodes.extend(special)
if pending:
nodes.append({"op": "exec", "code": pending})
return nodes
def renpy_call(self, e: Expr, line: Line) -> list[Node] | None:
"""Turn common renpy.* calls into native script nodes."""
@@ -332,17 +345,66 @@ class RpyParser:
if "channel" in kw and kw["channel"].get("k") == "const":
ch = kw["channel"]["v"]
return [{"op": "stop", "ch": ch, "fadeout": _const(kw.get("fadeout"))}]
if fname == "renpy.show" and args and args[0].get("k") == "const":
return [{"op": "show", "img": {"name": str(args[0]["v"]).split()}, "at": [],
"with": None}]
if fname == "renpy.hide" and args and args[0].get("k") == "const":
return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}]
if fname == "renpy.show" and args:
at = self.at_list(kw.get("at_list"), line)
if at is None:
return None
node: Node = {"op": "show", "at": at, "with": None,
"img": {"name": str(args[0]["v"]).split()} if args[0].get("k") == "const"
else {"expr": args[0]}}
if "zorder" in kw:
node["zorder"] = kw["zorder"]
return [node]
if fname == "renpy.hide" and args:
if args[0].get("k") == "const":
return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}]
return [{"op": "hide", "tag": "", "tagExpr": args[0]}]
if fname == "renpy.say" and len(args) >= 2:
return [self.say_node(args[0], args[1])]
if fname == "narrator" or fname in self.d.characters:
if len(args) == 1 and set(kw) <= {"interact"}:
who = {"k": "const", "v": None} if fname == "narrator" else {"k": "name", "id": fname}
node = self.say_node(who, args[0])
if "interact" in kw and kw["interact"] == {"k": "const", "v": False}:
node["interact"] = False
return [node]
if fname == "renpy.scene":
return [{"op": "scene", "img": None, "at": [], "with": None}]
if fname == "renpy.with_statement" and args:
return [{"op": "with", "t": self.transition_expr(args[0])}]
return None
def at_list(self, e: Expr | None, line: Line) -> list[dict[str, Any]] | None:
"""``at_list=[a, b]`` of renpy.show: names of transforms, or None when not understood."""
if e is None:
return []
if e.get("k") not in ("list", "tuple"):
return None
out = []
for item in e["v"]:
if item.get("k") != "name":
return None
out.append(self.transform_ref(item["id"], line))
return out
def say_node(self, who: Expr, what: Expr) -> Node:
"""A say command from Python: literal text stays translatable, anything else is evaluated."""
node: Node = {"op": "say", "what": ""}
if (what.get("k") == "call" and what["f"].get("k") == "name" and what["f"]["id"] in ("_", "__")
and len(what["a"]) == 1):
what = what["a"][0] # _("text"): the marker only asks for translation, which says do anyway
if what.get("k") == "const" and isinstance(what["v"], str):
node["what"] = what["v"]
else:
node["whatExpr"] = what
if who.get("k") == "const" and who["v"] is None:
pass
elif who.get("k") == "name" and (who["id"] in self.d.characters or who["id"] == "narrator"):
node["who"] = who["id"]
else:
node["whoExpr"] = who
return node
# init / python ----------------------------------------------------------------
def st_init(self, line: Line) -> list[Node]:
@@ -369,8 +431,7 @@ class RpyParser:
if c.keyword("early"):
self.init_python(line.block_source(), line)
return []
code = self.python(line.block_source(), line)
return [{"op": "exec", "code": code}] if code else []
return self.python_nodes(self.python(line.block_source(), line), line)
def st_rpy(self, line: Line) -> list[Node]:
return []
@@ -616,6 +677,38 @@ class RpyParser:
MOTION_WORDS = {"linear", "ease", "easein", "easeout", "easeinout", "parallel", "choice", "repeat",
"block", "on"}
TIMELINE_WORDS = {"function", "time", "contains", "animation", "parallel", "choice", "block", "on"}
def atl_image_line(self, line: Line) -> tuple[dict, Any] | None:
"""A picture inside an ATL block: `"a.png" with dissolve` -> (ImageDef, transition)."""
if not self.displayable_line(line):
return None
match = re.search(r"\s+with\s+", line.text)
if match:
picture, transition = line.text[:match.start()].strip(), line.text[match.end():].strip()
else:
picture, transition = line.text.strip(), ""
expr = self.expr(picture, line)
image = self.displayable_expr(expr, line) if expr else None
if image is None:
return None
return image, (self.transition(transition, line) if transition.strip() else None)
def single_motion(self, lines: list[Line]) -> bool:
"""One picture plus plain linear/ease motion, handled by the lighter `at` path."""
shown = [child for child in lines if self.displayable_line(child)]
return len(shown) == 1 and not any(
(Cursor(child.text).peek_word() or "") in self.TIMELINE_WORDS for child in lines)
def needs_timeline(self, lines: list[Line]) -> bool:
"""Whether an `image` block is more than a static picture or a plain slideshow."""
for child in lines:
word = Cursor(child.text).peek_word() or ""
if word in self.TIMELINE_WORDS or WARPER.match(word):
return True
if word == "repeat" and child.text.strip() != "repeat":
return True
return False
def displayable_line(self, child: Line) -> bool:
if self.cursor(child).string() is not None:
@@ -623,9 +716,51 @@ class RpyParser:
m = _CALL_LINE.match(child.text)
return bool(m and m.group(1) in _DISPLAYABLE_CALLS)
def expression_picture(self, line: Line) -> dict[str, Any] | None:
"""``image x:`` whose first line is a call computing the picture (``get_face("base")``) followed by
transform lines: the picture is evaluated when the image is shown, the transforms are ATL."""
if not line.block:
return None
first = line.block[0]
match = _CALL_LINE.match(first.text)
word = Cursor(first.text).peek_word() or ""
if (not match or self.displayable_line(first) or match.group(1) in self.d.transforms
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
return None
rest = line.block[1:]
expr = self.expr(first.text, first)
if expr is None or not rest:
return None
issues: list[tuple[str, str]] = []
props, animation = self.atl_spec(rest, issues.append, set())
if issues or not animation:
return None
self.d.warn("Изображение задано выражением", self.where(first), blocking=False)
return {"expr": expr, "at": [{"props": props, "atl": animation}]}
def atl_image(self, line: Line) -> dict[str, Any] | None:
if (picture := self.expression_picture(line)) is not None:
return picture
if self.needs_timeline(line.block) and not self.single_motion(line.block):
props, timeline = self.atl_spec(line.block)
if timeline is None:
return None
return {"timeline": timeline}
shown = [child for child in line.block if self.displayable_line(child)]
rest = [child for child in line.block if not self.displayable_line(child)]
if len(shown) == 1 and rest:
# a picture with computed properties (matrixcolor OpacityMatrix(...)): try the timeline path quietly
issues: list[tuple[str, str]] = []
props, animation = self.atl_spec(rest, issues.append, set())
if animation and not issues:
first = self.cursor(shown[0]).string()
if first is not None:
img = self.displayable_expr(const(first), shown[0])
else:
expr = self.expr(shown[0].text.split(" with ")[0], shown[0])
img = self.displayable_expr(expr, shown[0]) if expr else None
if img:
return {**img, "at": [*img.get("at", []), {"props": props, "atl": animation}]}
if len(shown) == 1 and any(
(word := Cursor(child.text).peek_word()) in self.MOTION_WORDS or WARPER.match(word or "")
for child in rest):
@@ -881,7 +1016,8 @@ class RpyParser:
uses.add(name)
parser = AtlParser(warn, lambda name: self.d.transforms.get(name) if name not in self.d.atl
and name in self.d.transforms else None, on_use)
and name in self.d.transforms else None, on_use,
lambda line: self.atl_image_line(line))
block = parser.block(lines)
static = static_props(block)
if static is not None: