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)})
+72 -3
View File
@@ -111,11 +111,27 @@ SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullActi
Quit MainMenu If GetTooltip StaticValue VariableValue AnimatedValue FieldValue SelectedIf
SensitiveIf Queue ToggleScreen Language ShowMenu OpenURL Crop LiveCrop Solid Frame Null Transform
DynamicDisplayable Color Dissolve Fade ImageDissolve MoveTransition CropMove PushMove Pixellate
ComposeTransition With""".split())
ComposeTransition With Text SetLocalVariable QuickSave QuickLoad AlphaMask Fixed At
TintMatrix BrightnessMatrix SaturationMatrix ContrastMatrix InvertMatrix OpacityMatrix HueMatrix
ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split())
SCREEN_CALLS |= {"im." + name for name in (
"MatrixColor Scale FactorScale Flip Crop Grayscale Sepia Alpha Blur Composite Tile Rotozoom "
"matrix.brightness matrix.contrast matrix.saturation matrix.tint matrix.identity matrix.invert "
"matrix.opacity matrix.hue matrix.colorize").split()}
SCREEN_CALLS |= {"Image", "re.sub", "re.match", "re.search", "re.findall", "re.split", "re.escape",
"copy.copy", "copy.deepcopy", "time.time", "time.strftime", "time.sleep",
"datetime.now", "datetime.datetime.now", "datetime.today", "datetime.date.today",
"collections.defaultdict", "collections.OrderedDict", "renpy.partial", "renpy.loadable",
"renpy.is_init_phase", "renpy.call_stack_depth", "renpy.const", "renpy.screenshot",
"renpy.Keymap", "renpy.register_shader", "renpy.register_statement", "renpy.redraw",
"renpy.stop_predict", "renpy.map_event", "renpy.count_displayables_in_layer",
"renpy.displayable", "renpy.game.context", "renpy.unlink_save", "json.dumps", "json.loads",
"_window_hide", "_window_show", "functools.partial"}
CALLS |= SCREEN_CALLS
CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "renpy.seen_image",
"renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction",
"renpy.get_screen", "renpy.call_screen"}
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha zfill ljust rjust center partition rpartition rsplit splitlines isupper islower isspace isalnum swapcase rfind has_key iteritems iterkeys itervalues strftime isoformat weekday group groups groupdict start end".split())
def _bound_names(root) -> tuple[set[str], set[str]]:
@@ -197,6 +213,59 @@ def mark_screen_calls(data) -> None:
[data.screens[n] for n in data.screen_used if n in data.screens]])
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"}
def reachable_init_code(data, used_screens) -> list:
"""The part of `init python` that can run: startup statements plus every function, class and
method that something reachable refers to (by name or as an attribute).
Definitions nobody refers to - lexers of custom statements, helpers of unused tools - are left
out of the audit; if the game ever calls one, the player stops with an explicit error.
"""
roots: list = []
definitions: dict[str, list] = {}
def collect(statements: list) -> None:
for statement in statements:
op = statement.get("op")
if op == "def":
definitions.setdefault(statement["name"], []).append(statement)
elif op == "class":
definitions.setdefault(statement["name"], []).append(statement)
collect([m for m in statement["body"] if m.get("op") == "def"])
else:
roots.append(statement)
collect(data.init_code)
result: list = list(roots)
taken: set[int] = set()
queue: list = [roots, list(data.modules.values()), data.defaults, data.images, list(used_screens.values())]
while queue:
fragment = queue.pop()
stack = [fragment]
while stack:
value = stack.pop()
if isinstance(value, list):
stack.extend(value)
elif isinstance(value, dict):
name = value.get("id") if value.get("k") == "name" else value.get("a") if value.get("k") == "attr" else None
for node in definitions.get(name, ()) if name else ():
if id(node) in taken:
continue
taken.add(id(node))
if node["op"] == "class":
# class-level assignments and the methods Python calls implicitly
own = [m for m in node["body"] if m.get("op") != "def" or m["name"] in IMPLICIT_METHODS]
result.extend(own)
queue.append(own)
else:
result.append(node)
queue.append(node)
stack.extend(value.values())
return result
def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None."""
mark_screen_calls(data)
@@ -241,5 +310,5 @@ def audit_ir(data) -> None:
visit(definition, f"screen {name}")
for name in sorted(data.screen_used - set(data.screens)):
data.warn("Не найден screen", name)
visit(data.init_code, "init")
visit(reachable_init_code(data, used_screens), "init")
visit(data.images, "image")
+28
View File
@@ -0,0 +1,28 @@
"""Splitting Ren'Py "simple expression" values out of a line (screens and ATL)."""
import re
OPERATORS = {"+", "-", "*", "/", "//", "%", "**", "==", "!=", "<", ">", "<=", ">=", "and", "or", "in", "is",
"if", "else", "not"}
def read_value(words: list[str], i: int) -> tuple[str, int]:
"""One property value starting at words[i]. Decompiled sources keep operators unparenthesised
(``text "Day " + str(day)``), so a value continues while the next word is an operator."""
parts = [words[i]]
i += 1
if parts[0] in ("not", "-") and i < len(words):
parts.append(words[i])
i += 1
while i < len(words):
if words[i].startswith("(") and re.search(r"[\w\])]$", parts[-1]): # `ToggleVariable ("x")`
parts[-1] += words[i]
i += 1
elif parts[-1].endswith(",") or parts[-1] in OPERATORS: # `A(), B()` / `a +` continue
parts.append(words[i])
i += 1
elif i + 1 < len(words) and words[i] in OPERATORS:
parts.extend([words[i], words[i + 1]])
i += 2
else:
break
return " ".join(parts), i
+19
View File
@@ -344,6 +344,8 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st
if "frames" in img:
img["frames"] = [{**f, "img": _resolve_image(f["img"], aliases, missing)}
for f in img["frames"]]
if "timeline" in img:
img["timeline"] = _resolve_timeline(img["timeline"], aliases, missing)
if "layered" in img:
layers = []
for layer in img["layered"]:
@@ -359,6 +361,23 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st
return img
def _resolve_timeline(block: dict[str, Any], aliases: dict[str, str], missing: set[str]) -> dict[str, Any]:
"""Resolve the pictures an ATL timeline shows."""
def step(s: dict[str, Any]) -> dict[str, Any]:
s = dict(s)
if s["op"] in ("image", "contains"):
s["img"] = _resolve_image(s["img"], aliases, missing)
elif s["op"] == "parallel":
s["blocks"] = [_resolve_timeline(b, aliases, missing) for b in s["blocks"]]
elif s["op"] == "choice":
s["blocks"] = [{**b, "block": _resolve_timeline(b["block"], aliases, missing)} for b in s["blocks"]]
elif s["op"] in ("block", "on"):
s["block"] = _resolve_timeline(s["block"], aliases, missing)
return s
return {**block, "steps": [step(s) for s in block["steps"]]}
def anchors(labels: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Positions of `label` markers (nested labels, named menus) inside label bodies."""
found: dict[str, dict[str, Any]] = {}
+47 -1
View File
@@ -24,7 +24,8 @@ CMP_OPS = {
UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports.
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"}
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
"itertools", "string", "functools", "operator", "json"}
class Unsupported(Exception):
@@ -177,6 +178,8 @@ def stmt(node: ast.stmt) -> list[Stmt]:
case ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=final):
return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers],
"else": stmts(orelse), "final": stmts(final)}]
case ast.Match(subject=subject, cases=cases):
return match_statement(subject, cases)
case ast.Raise(exc=exc):
return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped
case ast.Assert(test=test, msg=msg):
@@ -273,3 +276,46 @@ def handler(node: ast.ExceptHandler) -> dict[str, Any]:
"""``types`` is None for a bare ``except:``; ``name`` binds the exception."""
return {"types": None if node.type is None else expr(node.type), "name": node.name,
"body": stmts(node.body)}
_match_counter = 0
def match_statement(subject: ast.expr, cases: list[ast.match_case]) -> list[Stmt]:
"""`match` becomes an if/elif chain over a temporary holding the subject."""
global _match_counter
_match_counter += 1
temp = f"_pgm_{_match_counter}"
name = {"k": "name", "id": temp}
def pattern(p: ast.pattern) -> tuple[Expr, list[Stmt]]:
match p:
case ast.MatchValue(value=value):
return {"k": "cmp", "l": name, "ops": ["=="], "r": [expr(value)]}, []
case ast.MatchSingleton(value=value):
return {"k": "cmp", "l": name, "ops": ["is"], "r": [const(value)]}, []
case ast.MatchAs(pattern=None, name=None):
return const(True), []
case ast.MatchAs(pattern=None, name=capture):
return const(True), [{"op": "set", "t": {"k": "name", "id": capture}, "v": name}]
case ast.MatchAs(pattern=inner, name=capture):
cond, binds = pattern(inner)
if capture:
binds = [*binds, {"op": "set", "t": {"k": "name", "id": capture}, "v": name}]
return cond, binds
case ast.MatchOr(patterns=alternatives):
parts = [pattern(a) for a in alternatives]
if any(binds for _, binds in parts):
raise Unsupported("match: захват внутри альтернативы")
return {"k": "bool", "op": "or", "v": [c for c, _ in parts]}, []
raise Unsupported(f"match: шаблон {type(p).__name__}")
chain: list[Stmt] = []
for case in reversed(cases):
cond, binds = pattern(case.pattern)
if case.guard is not None:
if binds:
raise Unsupported("match: захват вместе с условием guard")
cond = {"k": "bool", "op": "and", "v": [cond, expr(case.guard)]}
chain = [{"op": "if", "c": cond, "then": [*binds, *stmts(case.body)], "else": chain}]
return [{"op": "set", "t": name, "v": expr(subject)}, *chain]
+1 -1
View File
@@ -11,7 +11,7 @@ Node = dict[str, Any]
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size", "blur"}
"align", "pos", "anchor", "xysize", "size", "blur", "matrixcolor", "xycenter"}
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
+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:
+5 -28
View File
@@ -21,6 +21,7 @@ from typing import Any
from app.converter import pyexpr
from app.converter.atl import WARPER
from app.converter.exprtokens import read_value
from app.converter.pyexpr import Unsupported
from app.converter.rpy_lexer import Line
@@ -41,7 +42,7 @@ ymargin left_margin right_margin top_margin bottom_margin width height""".split(
LOOK = set("""background foreground idle hover insensitive selected selected_idle selected_hover
selected_insensitive auto ground left_bar right_bar top_bar bottom_bar thumb alpha zoom rotate xzoom yzoom
clipping fit_first xfit yfit box_reverse box_wrap order_reverse transpose rows cols crop size_group
xtile ytile""".split())
xtile ytile matrixcolor blur""".split())
TEXT = set("""color size font bold italic underline strikethrough outlines text_align textalign line_spacing
kerning layout first_indent min_width antialias xalign yalign justify hyperlink_functions slow_cps""".split())
BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_mask mouse hover_sound
@@ -58,8 +59,6 @@ STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "
ALL = LAYOUT | LOOK | TEXT | BEHAVIOUR
SCREEN_PROPS = {"zorder", "modal", "tag", "variant", "style_prefix", "layer", "sensitive", "style"}
DESKTOP_VARIANTS = {"pc", "large", "web", "medium", "small"}
OPERATORS = {"+", "-", "*", "/", "//", "%", "**", "==", "!=", "<", ">", "<=", ">=", "and", "or", "in", "is",
"if", "else", "not"}
GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\)|\[.*\])$", re.S)
@@ -110,29 +109,6 @@ def tokens(text: str) -> list[str]:
return out
def read_value(words: list[str], i: int) -> tuple[str, int]:
"""One property value starting at words[i]. Decompiled sources keep operators unparenthesised
(``text "Day " + str(day)``), so a value continues while the next word is an operator."""
parts = [words[i]]
i += 1
if parts[0] in ("not", "-") and i < len(words):
parts.append(words[i])
i += 1
while i < len(words):
if words[i].startswith("(") and re.search(r"[\w\])]$", parts[-1]): # `ToggleVariable ("x")`
parts[-1] += words[i]
i += 1
elif parts[-1].endswith(",") or parts[-1] in OPERATORS: # `A(), B()` / `a +` continue with the next word
parts.append(words[i])
i += 1
elif i + 1 < len(words) and words[i] in OPERATORS:
parts.extend([words[i], words[i + 1]])
i += 2
else:
break
return " ".join(parts), i
class ScreenParser:
def __init__(self, warn: Callable[[str, Line], None], transform_ref: Callable[[str, Line], dict],
atl: Callable[[list[Line]], tuple[dict, dict | None]] | None = None):
@@ -191,7 +167,8 @@ class ScreenParser:
i = 0
while i < len(lines):
line = lines[i]
words = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip())
text = line.text.rstrip(":").strip() if line.block else line.text.strip()
words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text))
i += 1
if not words:
continue
@@ -199,7 +176,7 @@ class ScreenParser:
if first in ("if", "showif"):
branches = [self.branch(first, words, line)]
while i < len(lines):
nxt = tokens(lines[i].text.rstrip(":").strip())
nxt = tokens(re.sub(r"^(elif|else)\(", r"\1 (", lines[i].text.rstrip(":").strip()))
if not nxt or nxt[0] not in ("elif", "else"):
break
branches.append(self.branch(nxt[0], nxt, lines[i]))
+1 -1
View File
@@ -75,7 +75,7 @@ def validate(doc, manifest):
raise ValueError("Некорректный оператор")
identity(n)
op = n["op"]
for key in ("expr", "c", "d"):
for key in ("expr", "c", "d", "whatExpr", "whoExpr", "tagExpr"):
if isinstance(n.get(key), dict):
expression(n[key])
if op in ("call_screen", "show_screen"):
+1 -2
View File
@@ -102,8 +102,7 @@ def test_renpy_atl_animation_becomes_a_timeline_not_a_static_pose():
def test_renpy_atl_constructs_without_a_runtime_are_still_rejected():
for body in ("function slide", "time 1.0:\n xalign 1.0", "matrixcolor TintMatrix('#f00')",
"contains:\n 'a.png'"):
for body in ("time 1.0:\n xalign 1.0", "knot (0, 0)", "circles 0.5", "contains 'a.png'"):
unused = script(f'transform odd:\n {body}\nlabel start:\n "Hi"')
require_supported_scripts(unused) # nothing shows it, so it cannot break the game
used = script(f'transform odd:\n {body}\nlabel start:\n show eileen at odd\n "Hi"')