358 lines
16 KiB
Python
358 lines
16 KiB
Python
"""Ren'Py ATL (animation and transformation language) → PGVN timeline.
|
||
|
||
A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps:
|
||
|
||
* ``{"op": "set", "props": {...}}`` – assign properties at once
|
||
* ``{"op": "move", "warp": "linear|ease|easein|easeout", "t": seconds, "props": {...}}``
|
||
* ``{"op": "pause", "t": seconds}``
|
||
* ``{"op": "parallel", "blocks": [block, ...]}``
|
||
* ``{"op": "choice", "blocks": [{"w": weight, "block": block}, ...]}``
|
||
* ``{"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):``).
|
||
|
||
Everything else (``function``, ``contains``, ``time``, expressions, shaders, matrix
|
||
colours) is reported through ``warn`` so the game is not imported half-working.
|
||
"""
|
||
import ast
|
||
import re
|
||
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]
|
||
CALL_LINE = re.compile(r"^[A-Za-z_]\w*\(.*\)$", re.S)
|
||
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))?$")
|
||
LEADING_ZERO = re.compile(r"(?<![\w.])0+(\d)(?![\d.]*[\w.])")
|
||
SPLIT_SIGN = re.compile(r"^([a-z_]+)(-[\d.]+)$")
|
||
IGNORED = {"subpixel", "animation", "around", "alignaround", "rotate_pad"} # no visible effect in the browser
|
||
# 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")
|
||
NAMED_POSITIONS = {
|
||
"left": {"xpos": 0.0, "xanchor": 0.0}, "right": {"xpos": 1.0, "xanchor": 1.0},
|
||
"center": {"xpos": 0.5, "xanchor": 0.5}, "truecenter": {"xpos": 0.5, "xanchor": 0.5, "ypos": 0.5, "yanchor": 0.5},
|
||
"top": {"xpos": 0.5, "xanchor": 0.5, "ypos": 0.0, "yanchor": 0.0},
|
||
"topleft": {"xpos": 0.0, "xanchor": 0.0, "ypos": 0.0, "yanchor": 0.0},
|
||
"topright": {"xpos": 1.0, "xanchor": 1.0, "ypos": 0.0, "yanchor": 0.0},
|
||
}
|
||
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default", "hover", "idle",
|
||
"selected_hover", "selected_idle", "insensitive", "selected_insensitive", "selected"}
|
||
|
||
|
||
GLUED_NUMBER = re.compile(r"^(\d+(?:\.\d*)?|\.\d+)([A-Za-z_]\w*)$")
|
||
|
||
|
||
def tokens(text: str) -> list[str]:
|
||
"""Split on whitespace/commas outside brackets and quotes."""
|
||
words = _split(text)
|
||
out: list[str] = []
|
||
for word in words:
|
||
signed = SPLIT_SIGN.match(word)
|
||
if signed and signed[1] in POSITION_PROPS:
|
||
out.extend([signed[1], signed[2]])
|
||
continue
|
||
glued = GLUED_NUMBER.match(word)
|
||
if glued and not re.fullmatch(r"e\d+", glued[2]):
|
||
out.extend([glued[1], glued[2]])
|
||
else:
|
||
out.append(word)
|
||
return out
|
||
|
||
|
||
def _split(text: str) -> list[str]:
|
||
out, cur, depth, quote = [], [], 0, ""
|
||
for ch in text:
|
||
if quote:
|
||
cur.append(ch)
|
||
if ch == quote:
|
||
quote = ""
|
||
elif ch in "\"'":
|
||
quote = ch
|
||
cur.append(ch)
|
||
elif ch in "([{":
|
||
depth += 1
|
||
cur.append(ch)
|
||
elif ch in ")]}":
|
||
depth -= 1
|
||
cur.append(ch)
|
||
elif (ch.isspace() or ch == ",") and depth == 0:
|
||
if cur:
|
||
out.append("".join(cur))
|
||
cur = []
|
||
else:
|
||
cur.append(ch)
|
||
if cur:
|
||
out.append("".join(cur))
|
||
return out
|
||
|
||
|
||
class AtlParser:
|
||
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | 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
|
||
|
||
# --- helpers -------------------------------------------------------------------
|
||
|
||
def value(self, raw: str, line: Line) -> Any:
|
||
"""A literal, or ``{"e": expr}`` for anything computed at run time."""
|
||
try:
|
||
return ast.literal_eval(raw)
|
||
except (ValueError, SyntaxError):
|
||
pass
|
||
raw = LEADING_ZERO.sub(r"\1", raw) # `(0.5, 05)`: Ren'Py reads 05 as 5
|
||
try:
|
||
return ast.literal_eval(raw)
|
||
except (ValueError, SyntaxError):
|
||
pass
|
||
try:
|
||
return {"e": pyexpr.parse_expr(raw)}
|
||
except pyexpr.Unsupported:
|
||
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
|
||
return None
|
||
|
||
def number(self, raw: str, line: Line) -> Any:
|
||
value = self.value(raw, line)
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, dict):
|
||
return value
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
self.warn(f"Не поддерживается ATL: значение {raw}", line)
|
||
return None
|
||
return float(value)
|
||
|
||
def props(self, parts: list[str], line: Line) -> dict[str, Any]:
|
||
out: dict[str, Any] = {}
|
||
i = 0
|
||
while i < len(parts):
|
||
key = parts[i]
|
||
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 NAMED_POSITIONS:
|
||
out.update(NAMED_POSITIONS[key])
|
||
i += 1
|
||
continue
|
||
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)
|
||
return out
|
||
if i + 1 >= len(parts):
|
||
self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
|
||
return out
|
||
source, i = read_value(parts, i + 1)
|
||
while i < len(parts) and re.match(r"^[+\-*/]\s*[\w.(]", parts[i]): # `xpos edge +250`
|
||
source += " " + parts[i]
|
||
i += 1
|
||
knots = []
|
||
while i + 1 < len(parts) and parts[i] == "knot": # spline: the value is the end, knots the controls
|
||
knot, i = read_value(parts, i + 1)
|
||
knots.append(self.value(knot, line))
|
||
value = self.value(source, line)
|
||
if value is None or None in knots:
|
||
return out
|
||
if knots:
|
||
out[key] = {"spline": [list(k) if isinstance(k, tuple) else k for k in knots]
|
||
+ [list(value) if isinstance(value, tuple) else value]}
|
||
continue
|
||
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
|
||
return out
|
||
|
||
# --- block ----------------------------------------------------------------------
|
||
|
||
def block(self, lines: list[Line]) -> Block:
|
||
steps: list[dict[str, Any]] = []
|
||
repeat: Any = None
|
||
i = 0
|
||
while i < len(lines):
|
||
line = lines[i]
|
||
parts = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip())
|
||
if not parts:
|
||
i += 1
|
||
continue
|
||
if split := SPLIT_SIGN.match(parts[0]): # decompiled `xzoom-1`
|
||
if split[1] in POSITION_PROPS:
|
||
parts = [split[1], split[2], *parts[1:]]
|
||
first = parts[0]
|
||
i += 1
|
||
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)
|
||
elif first == "pause":
|
||
seconds = self.number(parts[1], line) if len(parts) > 1 else None
|
||
if seconds is not None:
|
||
steps.append({"op": "pause", "t": seconds})
|
||
elif first in ("parallel", "choice", "on", "block", "time", "contains", "function"):
|
||
i = self.compound(first, parts, lines, i - 1, steps)
|
||
elif WARPER.match(first):
|
||
self.move(first, parts, line, steps)
|
||
elif first in IGNORED:
|
||
continue
|
||
elif first in NAMED_POSITIONS:
|
||
props = dict(NAMED_POSITIONS[first])
|
||
if len(parts) > 1: # `truecenter zoom 10.0`
|
||
props.update(self.props(parts[1:], line))
|
||
steps.append({"op": "set", "props": props})
|
||
elif re.fullmatch(r"[A-Za-z_]\w*\[.+\]", first) and len(parts) == 1 and not line.block:
|
||
seconds = self.number(first, line) # `delays[1]`: a number picked from a list
|
||
if seconds is not None:
|
||
steps.append({"op": "pause", "t": seconds})
|
||
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)
|
||
# not a transform when the game defines a number of that name: then it is a pause
|
||
steps.append({"op": "set", "props": known} if known is not None
|
||
else {"op": "use", "name": first, "pause": {"k": "name", "id": first}})
|
||
elif re.match(r"^[\d.(]", first) and not line.block:
|
||
# decompiled `pause`: a bare number or arithmetic line is a wait
|
||
seconds = self.number(line.text.strip(), line)
|
||
if seconds is not None:
|
||
steps.append({"op": "pause", "t": seconds})
|
||
else:
|
||
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 CALL_LINE.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"])
|
||
# `delay(0.5, 2)` may also be a function returning a number of seconds to wait
|
||
return {"op": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"], "pause": call}
|
||
|
||
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:
|
||
self.warn(f"Не поддерживается ATL: у {warp} нет длительности", line)
|
||
return
|
||
seconds = self.number(parts[1], line)
|
||
props = self.props(parts[2:], line)
|
||
for child in line.block: # `linear 1.0:` followed by indented property lines
|
||
props.update(self.props(tokens(child.text), child))
|
||
if seconds is None or not props:
|
||
return
|
||
steps.append({"op": "move", "warp": "ease" if base == "easeinout" else base, "t": seconds, "props": props})
|
||
|
||
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 == "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)})
|
||
return index + 1
|
||
if first == "on":
|
||
events = [e for e in parts[1:] if e]
|
||
if not events or any(e not in EVENTS for e in events):
|
||
self.warn(f"Не поддерживается ATL: событие {' '.join(events)}", line)
|
||
steps.append({"op": "on", "events": events, "block": self.block(line.block)})
|
||
return index + 1
|
||
# consecutive `parallel:` / `choice:` clauses form one statement
|
||
group = []
|
||
j = index
|
||
while j < len(lines):
|
||
current = lines[j]
|
||
words = tokens(current.text.rstrip(":").strip())
|
||
if not words or words[0] != first:
|
||
break
|
||
weight: Any = 1.0
|
||
if first == "choice" and len(words) > 1:
|
||
weight = self.number(words[1], current) or 1.0
|
||
group.append((weight, self.block(current.block)))
|
||
j += 1
|
||
if first == "parallel":
|
||
steps.append({"op": "parallel", "blocks": [b for _, b in group]})
|
||
else:
|
||
steps.append({"op": "choice", "blocks": [{"w": w, "block": b} for w, b in group]})
|
||
return j
|
||
|
||
|
||
def computed(value: Any) -> bool:
|
||
if isinstance(value, dict):
|
||
return "e" in value
|
||
return isinstance(value, list) and any(computed(item) for item in value)
|
||
|
||
|
||
def static_props(block: Block) -> dict[str, Any] | None:
|
||
"""Merged properties when the block only assigns, else None (it needs the timeline)."""
|
||
if block["repeat"] is not None or any(step["op"] != "set" for step in block["steps"]):
|
||
return None
|
||
if any(computed(v) for step in block["steps"] for v in step["props"].values()):
|
||
return None
|
||
merged: dict[str, Any] = {}
|
||
for step in block["steps"]:
|
||
merged.update(step["props"])
|
||
return merged
|
||
|
||
|
||
def initial_props(block: Block) -> dict[str, Any]:
|
||
"""Properties assigned before the first animated step (the state the element is created in)."""
|
||
merged: dict[str, Any] = {}
|
||
for step in block["steps"]:
|
||
if step["op"] != "set":
|
||
break
|
||
merged.update({k: v for k, v in step["props"].items() if not computed(v)})
|
||
return merged
|