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
@@ -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)})
|
||||
|
||||
Reference in new issue
Block a user