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)})
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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]] = {}
|
||||
|
||||
@@ -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]
|
||||
@@ -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"}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
Reference in new issue
Block a user