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:
1 parent
62ece25902
commit
ea03cebf6a
21 files changed
+1264
-93
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user