Files
pgames-manager/backend/app/converter/rpy_parser.py
T
mrleo1nid f9eb5c1212
CI / frontend (push) Failing after 15s
CI / backend (push) Failing after 37s
feat: improve RenPy conversion and support MIST runtime
2026-10-25 23:16:33 +03:00

1673 lines
75 KiB
Python

"""Ren'Py script (.rpy) → PGVN IR."""
import ast
import re
from collections import Counter
from contextlib import contextmanager
from dataclasses import dataclass, field, replace
from typing import Any
from app.converter import pyexpr
from app.converter.atl import WARPER, AtlParser, initial_props, static_props
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
from app.converter.rpy_helpers import (
_CALL_LINE,
_DISPLAYABLE_CALLS,
_MEDIA_EXTS,
_NUMBER_LINE,
_SHOW_KEYWORDS,
POSITION_PROPS,
_assign_name,
_atl_props,
_const,
_dotted_name,
_jump_or_call,
_num,
_parse_py,
_split_top,
_static_props,
)
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
from app.converter.screens import ScreenParser, referenced_screens, tokens
from app.games.renpy_native import COMPILED_IMAGE_CLASSES, NATIVE_CLASSES, NATIVE_IMPORTS
Node = dict[str, Any]
TRANSITIONS: dict[str, dict[str, Any]] = {
"dissolve": {"t": "dissolve", "d": 0.5},
"fade": {"t": "fade", "out": 0.5, "hold": 0.0, "in": 0.5, "color": "#000000"},
"pixellate": {"t": "dissolve", "d": 1.0},
"move": {"t": "move", "d": 0.5},
"ease": {"t": "move", "d": 0.5},
"vpunch": {"t": "punch", "dir": "v", "d": 0.3},
"hpunch": {"t": "punch", "dir": "h", "d": 0.3},
"blinds": {"t": "dissolve", "d": 1.0},
"squares": {"t": "dissolve", "d": 1.0},
"wipeleft": {"t": "wipe", "dir": "left", "d": 1.0},
"wiperight": {"t": "wipe", "dir": "right", "d": 1.0},
"wipeup": {"t": "wipe", "dir": "up", "d": 1.0},
"wipedown": {"t": "wipe", "dir": "down", "d": 1.0},
"zoomin": {"t": "dissolve", "d": 0.5},
"zoomout": {"t": "dissolve", "d": 0.5},
}
for _side in ("left", "right", "top", "bottom"):
for _kind in ("movein", "moveout", "easein", "easeout", "slide", "slideaway",
"push", "irisin", "irisout"):
TRANSITIONS.setdefault(f"{_kind}{_side}", {"t": "slide", "dir": _side, "d": 0.5,
"kind": _kind})
TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pixellate",
"MoveTransition", "CropMove", "PushMove", "ComposeTransition",
"MultipleTransition", "Move"}
POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top",
"offscreenleft", "offscreenright", "default", "reset"}
CHANNELS = {"music", "sound", "voice", "audio", "movie"}
# Callables that look like transforms in `at` but are displayable/transition factories
KNOWN_FACTORIES = {"Position", "Transform", "Shake", "SnoopyShake", "Move", "Dissolve", "Fade", "Pause"}
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
# Creator-defined statements of the Ren'Py cookbook that the converter implements (see creator_statements)
CREATOR_STATEMENTS = {"random", "block"}
ENGINE_SETTINGS = {"config", "gui", "build", "preferences", "style", "_preferences"}
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
@dataclass
class ScriptData:
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
images: dict[str, dict[str, Any]] = field(default_factory=dict)
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
# every `transform name` of the game, known before any file is parsed (files are read in any order)
transform_names: set[str] = field(default_factory=set)
atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name
# A transform's unsupported constructs only matter once something uses it (Ren'Py's stock
# screens.rpy defines several that no game ever shows).
atl_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
atl_uses: dict[str, set[str]] = field(default_factory=dict)
atl_referenced: set[str] = field(default_factory=set)
screens: dict[str, dict[str, Any]] = field(default_factory=dict)
styles: dict[str, dict[str, Any]] = field(default_factory=dict)
# Like transforms, a screen only matters once something shows it
screen_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
screen_used: set[str] = field(default_factory=set)
statements: set[str] = field(default_factory=set) # creator-defined statements the game registers
random_counter: int = 0
# image name -> [(message, where)] of pictures the converter could not build; see audit_ir
deferred_images: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
_captured: list | None = None
# name -> (message, where) of definitions the converter could not translate; see audit_ir
deferred: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
audio: dict[str, str] = field(default_factory=dict)
defaults: list[list[Any]] = field(default_factory=list)
init_code: list[Stmt] = field(default_factory=list) # defines + init python, in order
gui: dict[str, Any] = field(default_factory=dict)
channels: dict[str, dict[str, Any]] = field(default_factory=dict)
config: dict[str, Any] = field(default_factory=dict)
build: dict[str, Any] = field(default_factory=dict)
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
warnings: Counter = field(default_factory=Counter)
blockers: Counter = field(default_factory=Counter)
blocker_examples: dict[str, str] = field(default_factory=dict)
warning_examples: dict[str, str] = field(default_factory=dict)
stats: Counter = field(default_factory=Counter)
@contextmanager
def capture(self):
"""Collect the warnings raised inside the block instead of recording them: (kind, where, blocking)."""
previous, self._captured = self._captured, []
try:
yield self._captured
finally:
self._captured = previous
def prescan(self, source: str) -> None:
"""Facts about the whole game that statements need before their own file is parsed: the names
of transforms, and the creator-defined statements the game registers."""
self.transform_names.update(re.findall(r"^[ ]*transform[ ]+([A-Za-z_]\w*)", source, re.M))
for name in re.findall(r"renpy\.register_statement\(\s*[\"'](\w+)[\"']", source):
if name in CREATOR_STATEMENTS:
self.statements.add(name)
def use_screen(self, name: str) -> None:
"""A screen is shown by the game: whatever it cannot do now blocks the import."""
self.screen_used.add(name)
for message, where in self.screen_pending.pop(name, []):
self.warn(message, where)
definition = self.screens.get(name)
if definition:
for other in referenced_screens(definition, self.screens):
if other not in self.screen_used or other in self.screen_pending:
self.use_screen(other)
def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
if self._captured is not None:
self._captured.append((kind, where, blocking))
return
self.warnings[kind] += 1
if blocking:
self.blockers[kind] += 1
if where:
self.blocker_examples.setdefault(kind, where)
if where and kind not in self.warning_examples:
self.warning_examples[kind] = where
class RpyParser:
def __init__(self, data: ScriptData, filename: str):
self.d = data
self.filename = filename
self.global_label = ""
def label_ref(self, name: str) -> str:
"""Resolve `.local` label names against the enclosing global label."""
return self.global_label + name if name.startswith(".") else name
# --- helpers -------------------------------------------------------------
def where(self, line: Line) -> str:
return f"{self.filename}:{line.lineno}"
def cursor(self, line: Line, text: str | None = None) -> Cursor:
return Cursor(line.text if text is None else text, self.filename, line.lineno)
def expr(self, source: str, line: Line, default: Expr | None = None) -> Expr | None:
try:
return pyexpr.parse_expr(source)
except Unsupported as e:
self.d.warn(f"Выражение: {e}", self.where(line))
return default
def python(self, source: str, line: Line) -> list[Stmt]:
"""Translate python code statement by statement, skipping unsupported ones."""
try:
tree = _parse_py(source)
except SyntaxError as e:
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
return []
out: list[Stmt] = []
for node in tree.body:
try:
out.extend(pyexpr.stmt(node))
except Unsupported as e:
self.d.warn(f"Python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
return out
# --- top level -------------------------------------------------------------
def parse(self, source: str) -> None:
tree = parse_tree(source, self.filename)
labels: list[Node] = []
current: Node | None = None
index = 0
while index < len(tree):
line = tree[index]
index += 1
c = self.cursor(line)
first = c.peek_word()
if first != "label":
# statements after a label without a block (`label x`) or before any label; take the whole
# run at once so that an `if` / `else` chain stays together
run = [line]
while index < len(tree) and Cursor(tree[index].text).peek_word() != "label":
run.append(tree[index])
index += 1
nodes = self.block(run, top_level=True)
if nodes and current is not None:
current["body"].extend(nodes)
elif nodes:
labels.append(current := {"name": f"_{self.filename}_{line.lineno}", "body": nodes})
continue
if first == "label":
name, params = self.label_header(line)
if not name.startswith("."):
self.global_label = name
current = {"name": self.label_ref(name), "body": self.block(line.block)}
if params:
current["params"] = params
labels.append(current)
continue
self.d.modules[self.filename] = labels
def block(self, lines: list[Line], top_level: bool = False) -> list[Node]:
out: list[Node] = []
i = 0
while i < len(lines):
line = lines[i]
word = Cursor(line.text).peek_word()
if word == "if":
branches, i = self.if_chain(lines, i)
out.append({"op": "if", "branches": branches})
continue
if word == "label":
# a label inside a block is an entry point in the middle of the flow:
# keep a marker the player can jump to and inline the label's block
name, params = self.label_header(line)
if not name.startswith("."):
self.global_label = name
marker: Node = {"op": "label", "name": self.label_ref(name)}
if params:
marker["params"] = params
out.append(marker)
out.extend(self.block(line.block))
i += 1
continue
out.extend(self.statement(line, top_level=top_level))
i += 1
return out
def label_header(self, line: Line) -> tuple[str, list[list[Any]] | None]:
c = self.cursor(line)
c.keyword("label")
name = c.label_name()
if not name:
raise c.error("ожидалось имя метки")
params = None
if c.peek("("):
sig = c.until(set(), ":")
try:
fn = _parse_py(f"def _f{sig}: pass").body[0]
args = fn.args
defaults = [None] * (len(args.args) - len(args.defaults)) + list(args.defaults)
params = [[a.arg, pyexpr.expr(d) if d is not None else None]
for a, d in zip(args.args, defaults)]
except (SyntaxError, Unsupported):
self.d.warn("Не разобраны параметры метки", self.where(line))
return name, params
def call_args(self, c: Cursor, line: Line) -> dict[str, Any] | None:
if not c.peek("("):
return None
src = c.until({"from"})
try:
call = _parse_py(f"_f{src}", mode="eval").body
return {"a": [pyexpr.expr(a) for a in call.args],
"kw": {k.arg: pyexpr.expr(k.value) for k in call.keywords}}
except (SyntaxError, Unsupported, AttributeError):
self.d.warn("Не разобраны аргументы call", self.where(line))
return None
def if_chain(self, lines: list[Line], i: int) -> tuple[list[Node], int]:
branches: list[Node] = []
while i < len(lines):
line = lines[i]
c = self.cursor(line)
kw = c.word()
if kw in ("if", "elif") and (kw == "if") == (not branches):
cond = c.rest().removesuffix(":").strip()
branches.append({"c": self.expr(cond, line, const(False)),
"body": self.block(line.block)})
elif kw == "else" and branches:
branches.append({"c": None, "body": self.block(line.block)})
i += 1
break
else:
break
i += 1
return branches, i
# --- statements ----------------------------------------------------------------
def statement(self, line: Line, top_level: bool = False) -> list[Node]:
text = line.text
if text.startswith("$"):
return self.dollar(text[1:].strip(), line)
c = self.cursor(line)
word = c.peek_word()
handler = getattr(self, f"st_{word}", None) if word else None
if word in CREATOR_STATEMENTS and word in self.d.statements:
handler = getattr(self, f"cds_{word}")
if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)):
try:
return handler(line) or []
except ParseError as e:
# one odd statement (custom syntax, typo) must not drop the whole file
self.d.warn(f"Не разобран оператор {word}", f"{e.filename}:{e.lineno}")
return []
if word in SKIP_BLOCKS:
if word not in ("style", "translate"):
self.d.warn(f"Не поддерживается: {word}", self.where(line),
blocking=not (top_level and word == "screen"))
return []
say = self.say(line)
if say is not None:
return say
self.d.warn("Неизвестный оператор", f"{self.where(line)}: {text[:60]}")
return []
def _is_say(self, line: Line) -> bool:
c = self.cursor(line)
c.word()
while c.image_part() is not None and not c.peek('"') and not c.peek("'"):
pass
return c.string_raw() is not None or c.peek('"') or c.peek("'")
def dollar(self, code: str, line: Line) -> list[Node]:
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."""
if e.get("k") != "call":
return None
fname = _dotted_name(e["f"])
args, kw = e["a"], e["kw"]
if fname == "renpy.pause":
return [{"op": "pause", "d": args[0] if args else kw.get("delay")}]
if fname == "renpy.jump" and args:
return [_jump_or_call("jump", args[0])]
if fname == "renpy.call" and args:
return [_jump_or_call("call", args[0])]
if fname in ("renpy.music.play", "renpy.sound.play", "renpy.play"):
ch = "sound" if fname != "renpy.music.play" else "music"
if "channel" in kw and kw["channel"].get("k") == "const":
ch = kw["channel"]["v"]
if not args:
return None
node = {"op": "play", "ch": ch, "files": [args[0]], "loop": _const(kw.get("loop"))}
for key in ("fadein", "fadeout"):
if key in kw:
node[key] = _const(kw[key])
return [node]
if fname in ("renpy.music.stop", "renpy.sound.stop"):
ch = "music" if fname == "renpy.music.stop" else "sound"
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:
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]:
c = self.cursor(line)
c.keyword("init")
c.number()
if c.keyword("offset"):
return []
if c.keyword("python") or c.keyword("early"):
c.keyword("python")
self.init_python(line.block_source(), line)
return []
for child in line.block:
for node in self.statement(child, top_level=True):
if node["op"] == "exec":
self.d.init_code.extend(node["code"])
else:
self.d.warn("Оператор сцены внутри init", self.where(child))
return []
def st_python(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("python")
if c.keyword("early"):
self.init_python(line.block_source(), line)
return []
return self.python_nodes(self.python(line.block_source(), line), line)
def st_rpy(self, line: Line) -> list[Node]:
return []
def init_python(self, source: str, line: Line) -> None:
try:
tree = _parse_py(source)
except SyntaxError as e:
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
return
native = {n.name for n in tree.body if isinstance(n, ast.ClassDef) and n.name in NATIVE_CLASSES}
recipe_functions = self.creator_statements(tree)
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name in recipe_functions:
continue # the parse/next functions of a statement the converter implements itself
if self.is_recipe_registration(node):
continue
if isinstance(node, ast.ClassDef) and node.name in native:
continue # the player ships its own implementation (see NATIVE_CLASSES)
if isinstance(node, ast.ClassDef) and node.name in COMPILED_IMAGE_CLASSES:
# Image declarations using this factory are compiled by displayable_expr.
# Its Ren'Py render-API implementation must not execute during browser init.
# Calls outside converted images remain unknown and fail the strict audit.
continue
if native and isinstance(node, ast.Import) and {a.name for a in node.names} <= NATIVE_IMPORTS:
continue
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
and ast.unparse(node.value.func) == "renpy.music.register_channel"):
self.register_channel(node.value)
continue
if isinstance(node, ast.Assign) and len(node.targets) == 1:
tgt = node.targets[0]
name = _assign_name(tgt)
if name and self.define(name, ast.get_source_segment(source, node.value)
or ast.unparse(node.value), line):
continue
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
and ast.unparse(node.value.func) == "gui.init"
and len(node.value.args) == 2):
try:
self.d.gui["resolution"] = [ast.literal_eval(a) for a in node.value.args]
except ValueError:
pass
continue
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
and ast.unparse(node.value.func).startswith("build.")):
continue # distribution build settings (build.classify, build.name ...), no gameplay
try:
self.d.init_code.extend(pyexpr.stmt(node))
except Unsupported as e:
where = f"{self.filename}:{line.lineno + node.lineno}"
names = self.definition_names(node)
if names:
# a definition nobody refers to cannot hurt: it blocks only if reachable code names it
for name in names:
self.d.deferred.setdefault(name, []).append((f"init python: {e}", where))
else:
self.d.warn(f"init python: {e}", where)
@staticmethod
def is_recipe_registration(node: ast.stmt) -> bool:
call = node.value if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) else None
return bool(call and ast.unparse(call.func) == "renpy.register_statement" and call.args
and isinstance(call.args[0], ast.Constant) and call.args[0].value in CREATOR_STATEMENTS)
def creator_statements(self, tree: ast.Module) -> set[str]:
"""``renpy.register_statement("random", parse=..., next=...)`` as in the Ren'Py cookbook:
the statement is implemented by the converter, so the registration and its functions are dropped.
Returns the names of the functions that belonged to such registrations."""
functions: set[str] = set()
for node in tree.body:
if not self.is_recipe_registration(node):
continue
call = node.value # type: ignore[attr-defined]
self.d.statements.add(call.args[0].value)
functions.update(k.value.id for k in call.keywords
if k.arg in ("parse", "next", "execute", "predict", "lint") and isinstance(k.value, ast.Name))
return functions
@staticmethod
def definition_names(node: ast.stmt) -> list[str]:
"""Names a function, class or import statement binds."""
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
return [node.name]
if isinstance(node, ast.Import):
return [(a.asname or a.name).split(".")[0] for a in node.names]
if isinstance(node, ast.ImportFrom):
return [a.asname or a.name for a in node.names]
return []
def register_channel(self, call: ast.Call) -> None:
args = list(call.args) + [None] * 3
kw = {k.arg: k.value for k in call.keywords}
def lit(node, default=None):
try:
return ast.literal_eval(node) if node is not None else default
except ValueError:
return default
name = lit(args[0])
if isinstance(name, str):
self.d.channels[name] = {"mixer": lit(kw.get("mixer", args[1]), "sfx"),
"loop": bool(lit(kw.get("loop", args[2]), False))}
# define / default / image / transform -------------------------------------------
def st_define(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("define")
c.number()
name = c.dotted()
if not name:
raise c.error("ожидалось имя")
op = "="
for candidate in ("+=", "|=", "="):
if c.char(candidate):
op = candidate
break
value = c.rest()
if op != "=":
if name.partition(".")[0] in ENGINE_SETTINGS:
return [] # `define config.search_prefixes += [...]`: engine settings the converter does not model
self.d.warn("define с += не поддерживается", self.where(line))
return []
self.define(name, value, line)
return []
def define(self, name: str, value: str, line: Line) -> bool:
"""Handle `define name = value`. Returns False if value was not understood."""
head, _, attr = name.partition(".")
if head in ("config", "gui", "build", "preferences", "style", "_preferences"):
if attr:
try:
settings = self.d.config if head == "config" else self.d.build if head == "build" else self.d.gui
settings[attr] = pyexpr.literal(value)
except Unsupported:
pass
return True
if head == "audio" and attr:
try:
self.d.audio[attr] = str(pyexpr.literal(value))
except Unsupported:
pass
return True
if head == "store":
name = attr
e = self.expr(value, line)
if e is None:
return False
if "." in name:
# e.g. persistent.flag = ... : a plain assignment at init time
self.d.init_code.append({"op": "set", "t": pyexpr.parse_expr(name), "v": e})
return True
if e["k"] == "call":
fname = _dotted_name(e["f"])
if fname in ("Character", "DynamicCharacter", "ADVCharacter", "NVLCharacter"):
self.d.characters[name] = self.character(fname, e, line)
return True
if fname in TRANSITION_CLASSES or fname in TRANSITIONS:
self.d.transitions[name] = self.transition_expr(e)
return True
if fname in ("Transform", "Position"):
self.d.transforms[name] = _static_props(e["kw"])
return True
if e["k"] == "name" and e["id"] in TRANSITIONS:
self.d.transitions[name] = TRANSITIONS[e["id"]]
return True
if e["k"] == "name" and e["id"] in self.d.characters:
self.d.characters[name] = dict(self.d.characters[e["id"]])
return True
self.d.init_code.append({"op": "set", "t": {"k": "name", "id": name}, "v": e})
return True
def character(self, fname: str, e: Expr, line: Line) -> dict[str, Any]:
kw = e["kw"]
char: dict[str, Any] = {}
kind = kw.get("kind")
if kind and kind.get("k") == "name":
if kind["id"] == "nvl":
char["nvl"] = True
elif kind["id"] in self.d.characters:
char.update(self.d.characters[kind["id"]])
if fname == "NVLCharacter":
char["nvl"] = True
args = e["a"]
if args:
name = args[0]
if fname == "DynamicCharacter" or (kw.get("dynamic") or {}).get("v"):
char["nameExpr"] = (self.expr(name["v"], line, const(""))
if name.get("k") == "const" and isinstance(name["v"], str)
else name)
elif name.get("k") == "const":
char["name"] = name["v"]
else:
char["nameExpr"] = name
mapping = {"color": "color", "who_color": "color", "what_color": "whatColor",
"what_prefix": "whatPrefix", "what_suffix": "whatSuffix",
"who_prefix": "whoPrefix", "who_suffix": "whoSuffix", "image": "image",
"what_italic": "whatItalic", "what_bold": "whatBold"}
for key, target in mapping.items():
v = kw.get(key)
if v is not None and v.get("k") == "const":
char[target] = v["v"]
return char
def st_default(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("default")
c.number() # optional init priority
name = c.dotted()
if not name or not c.char("="):
raise c.error("ожидалось default имя = значение")
e = self.expr(c.rest(), line)
if e is not None:
self.d.defaults.append([name.removeprefix("store."), e])
return []
def st_image(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("image")
parts: list[str] = []
while (p := c.image_part()) is not None:
parts.append(p)
if not parts:
raise c.error("ожидалось имя изображения")
name = " ".join(parts)
with self.d.capture() as caught:
if c.char("="):
img = self.displayable(c.rest(), line)
elif c.char(":"):
img = self.atl_image(line)
else:
raise c.error("ожидалось = или :")
# a picture the converter cannot build matters only if something shows it (checked in audit_ir)
for kind, where, blocking in caught:
if blocking:
self.d.deferred_images.setdefault(name, []).append((kind, where))
else:
self.d.warn(kind, where, blocking=False)
if img is not None:
self.d.images[name] = img
return []
def displayable(self, source: str, line: Line) -> dict[str, Any] | None:
e = self.expr(source, line)
return self.displayable_expr(e, line) if e is not None else None
def displayable_expr(self, e: Expr, line: Line) -> dict[str, Any] | None:
if e["k"] == "const" and isinstance(e["v"], str):
s = e["v"]
if s.startswith("#"):
return {"color": s}
if "." in s and "/" in s or s.rsplit(".", 1)[-1].lower() in _MEDIA_EXTS:
return {"file": s}
return {"ref": s}
if e["k"] != "call":
self.d.warn("Изображение задано выражением", self.where(line))
return {"expr": e}
fname = _dotted_name(e["f"])
args, kw = e["a"], e["kw"]
if fname == "Solid" and args:
return {"color": _const(args[0])}
if fname == "Text" and args:
return {"textExpr": args[0]}
if fname in ("Movie",):
play = kw.get("play") or (args[0] if args else None)
if not play:
return None
if not isinstance(_const(play), str):
self.d.warn("Movie: динамическое имя файла не поддерживается", self.where(line))
return None
movie = {"movie": _const(play)}
if "mask" in kw and kw["mask"].get("k") != "const":
self.d.warn("Movie: динамическая маска не поддерживается", self.where(line))
return None
if "mask" in kw and _const(kw["mask"]) is not None:
if not isinstance(_const(kw["mask"]), str):
self.d.warn("Movie: маска должна быть именем файла", self.where(line))
return None
movie["movieMask"] = _const(kw["mask"])
if "loop" in kw:
loop = _const(kw["loop"])
if not isinstance(loop, (bool, int)) or loop not in (0, 1):
self.d.warn("Movie: динамический режим повтора не поддерживается", self.where(line))
return None
movie["movieLoop"] = bool(loop)
return movie
if fname in ("ConditionSwitch", "TransitionConditionSwitch"):
if fname == "TransitionConditionSwitch":
args = args[1:] # the first argument is the transition between the states
pairs = []
for i in range(0, len(args) - 1, 2):
cond = args[i]
cexpr = (self.expr(cond["v"], line, const(False))
if cond.get("k") == "const" and isinstance(cond["v"], str) else cond)
sub = self.displayable_expr(args[i + 1], line)
if sub:
pairs.append([cexpr, sub])
return {"switch": pairs}
if fname == "AlphaMask" and (len(args) >= 2 or {"child", "mask"} <= set(kw)):
child = self.lazy_displayable(args[0] if args else kw["child"], line)
mask = self.lazy_displayable(args[1] if len(args) > 1 else kw["mask"], line)
return {"alphaMask": {"img": child, "mask": mask}} if child and mask else None
if fname in ("Composite", "LiveComposite", "im.Composite") and args:
size = _const(args[0])
layers = []
for i in range(1, len(args) - 1, 2):
pos = _const(args[i])
sub = self.displayable_expr(args[i + 1], line)
if sub and isinstance(pos, (list, tuple)):
layers.append({"pos": list(pos), "img": sub})
return {"composite": layers, "size": list(size) if isinstance(size, (list, tuple))
else None}
# wrappers: Transform("x.png", zoom=.5), im.Scale(...), im.Flip(...), etc.
for a in args:
sub = self.displayable_expr(a, line) if a.get("k") in ("const", "call") else None
if sub:
props = _static_props(kw)
if fname in ("im.Flip", "Flip") and (_const(kw.get("horizontal")) is True):
props["xzoom"] = -1
if fname in ("im.Scale", "im.FactorScale") and len(args) >= 2:
if fname == "im.FactorScale":
props["zoom"] = _const(args[1])
elif len(args) >= 3:
props["size"] = [_const(args[1]), _const(args[2])]
if fname in ("im.Grayscale", "im.Sepia"):
props["filter"] = "grayscale" if fname == "im.Grayscale" else "sepia"
if props:
sub = {**sub, "props": {**sub.get("props", {}), **props}}
return sub
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
return None
def lazy_displayable(self, e: Expr, line: Line) -> dict[str, Any] | None:
"""A picture known now, or (for a computed one) an expression evaluated when it is shown."""
if e["k"] == "const" or (e["k"] == "call" and (_dotted_name(e["f"]) in _DISPLAYABLE_CALLS
or _dotted_name(e["f"]) == "AlphaMask")):
return self.displayable_expr(e, line)
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
return {"expr": e}
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
if re.match(r"^[\d.(]", child.text.strip()) and not _NUMBER_LINE.match(child.text.strip()):
return True # a computed pause: `0.5*speed`
if re.fullmatch(r"[A-Za-z_]\w*\[.+\]", child.text.strip()):
return True # a pause read from a list: `delays[1]`
if re.fullmatch(r"[A-Za-z_]\w*(\(.*\))?", child.text.strip()) and not self.displayable_line(child) \
and word not in ("repeat", "pause"):
return True # a transform or a number held by a variable: `wing_flap`, `random_delay(1, 2)`
return False
def displayable_line(self, child: Line) -> bool:
if self.cursor(child).string() is not None:
return True
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 an expression computing the picture (``get_face("base")`` or
``("a_" + str(n))``), optionally followed by transform lines: it is evaluated when the image is shown."""
if not line.block:
return None
first = line.block[0]
text = first.text.strip()
match = _CALL_LINE.match(text)
word = Cursor(first.text).peek_word() or ""
if not (match or text.startswith("(")):
return None
if match and (self.displayable_line(first) or match.group(1) in self.d.transforms or match.group(1) in self.d.transform_names
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
return None
expr = self.expr(text, first)
if expr is None:
return None
picture: dict[str, Any] = {"expr": expr}
rest = line.block[1:]
if rest:
issues: list[tuple[str, str]] = []
props, animation = self.atl_spec(rest, issues.append, set())
if issues or not animation:
return None
picture["at"] = [{"props": props, "atl": animation}]
self.d.warn("Изображение задано выражением", self.where(first), blocking=False)
return picture
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):
# one picture that moves, e.g. a spinning "click to continue" mark
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:
props, animation = self.atl_spec(rest)
spec: dict[str, Any] = {"props": props}
if animation:
spec["atl"] = animation
return {**img, "at": [*img.get("at", []), spec]}
frames: list[dict[str, Any]] = []
props: dict[str, Any] = {}
repeat = self.atl_frames(line.block, frames, props)
if not frames:
self.d.warn("ATL-изображение без кадров", self.where(line))
return None
if len(frames) == 1:
img = dict(frames[0]["img"])
else:
img = {"frames": frames, "repeat": repeat}
if props:
img["props"] = {**img.get("props", {}), **props}
return img
def atl_frames(self, lines: list[Line], frames: list[dict[str, Any]],
props: dict[str, Any]) -> bool:
"""Collect displayable frames of an ATL block. Returns whether it repeats."""
repeat = False
for child in lines:
c = self.cursor(child)
word = c.peek_word()
if word in ("contains", "block", "parallel", "choice"):
if word != "block":
self.d.warn(f"Не поддерживается ATL: {word}", self.where(child))
if not frames or word == "block":
repeat = self.atl_frames(child.block, frames, props) or repeat
continue
if word == "repeat":
if child.text.strip() != "repeat":
self.d.warn("Не поддерживается ATL: конечное число повторов", self.where(child))
repeat = True
continue
if word in ("pause",) or _NUMBER_LINE.match(child.text):
c.keyword("pause")
d = _num(c.number())
if frames and d is not None:
frames[-1]["d"] = frames[-1].get("d", 0) + d
continue
if word in ("function", "on", "event", "time", "transform"):
self.d.warn(f"Не поддерживается ATL: {word}", self.where(child))
continue
s = c.string()
if s is not None:
img = self.displayable_expr(const(s), child)
if img:
frames.append({"img": img})
continue
m = _CALL_LINE.match(child.text)
if m and m.group(1) in _DISPLAYABLE_CALLS:
e = self.expr(child.text.split(" with ")[0], child)
img = self.displayable_expr(e, child) if e else None
if img:
frames.append({"img": img})
continue
if m and not frames and not _atl_props(child.text):
# e.g. get_face_path("base"): an image computed by game code
e = self.expr(child.text, child)
if e is not None:
self.d.warn("Изображение задано выражением", self.where(child), blocking=False)
frames.append({"img": {"expr": e}})
continue
if not frames or not frames[-1].get("d"):
props.update(self.static_atl(child))
return repeat
def st_layeredimage(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("layeredimage")
parts: list[str] = []
while (p := c.image_part()) is not None:
parts.append(p)
name = " ".join(parts)
layers: list[dict[str, Any]] = []
at: list[dict[str, Any]] = []
for child in line.block:
cc = self.cursor(child)
kw = cc.word()
if kw == "always":
img = self.layer_image(cc, child, None)
if img:
layers.append({"always": img})
elif kw == "attribute":
layers.append(self.layer_attribute(cc, child, name, None))
elif kw == "group":
group = cc.word() or ""
auto = False
while not cc.eol():
w = cc.word()
if w == "auto":
auto = True
elif w in ("multiple", "prefix", "variant"):
cc.word() if w in ("prefix", "variant") else None
elif w is None:
break
for sub in child.block:
sc = self.cursor(sub)
if sc.keyword("attribute"):
layers.append(self.layer_attribute(sc, sub, name, group))
if auto:
layers.append({"autoGroup": group})
elif kw == "if":
cond = self.expr(cc.rest().removesuffix(":"), child, const(False))
img = self.layer_image(Cursor(""), child, None)
if img:
layers.append({"cond": [[cond, img]]})
elif kw in ("elif", "else"):
img = self.layer_image(Cursor(""), child, None)
if img and layers and "cond" in layers[-1]:
cond = None
if kw == "elif":
cond = self.expr(cc.rest().removesuffix(":"), child, const(False))
layers[-1]["cond"].append([cond, img])
elif kw == "at":
for item in _split_top(cc.rest()):
at.append(self.transform_ref(item, child))
self.d.images[name] = {"layered": layers, "at": at}
self.d.stats["layeredimage"] += 1
return []
def layer_attribute(self, c: Cursor, line: Line, image: str, group: str | None
) -> dict[str, Any]:
attr = c.image_part() or ""
layer: dict[str, Any] = {"attr": attr, "group": group}
img = None
while not c.eol():
if c.peek('"') or c.peek("'"):
img = self.displayable_expr(const(c.string()), line)
continue
w = c.word()
if w == "default":
layer["default"] = True
elif w == "null":
layer["null"] = True
elif w in ("if_any", "if_all", "if_not"):
src = c.until({"if_any", "if_all", "if_not", "default", "null", "variant", "at"},
":")
try:
val = pyexpr.literal(src)
layer[w] = [val] if isinstance(val, str) else list(val)
except Unsupported:
pass
elif w in ("variant", "at"):
c.until({"if_any", "if_all", "if_not", "default", "null"}, ":")
elif w is None:
break
if img is None and line.block:
img = self.layer_image(Cursor(""), line, None)
if img is None and not layer.get("null"):
# Ren'Py's default naming: <image>_<group>_<attribute>
img = {"ref": "_".join(x for x in (image.replace(" ", "_"), group, attr) if x)}
if img:
layer["img"] = img
return layer
def layer_image(self, c: Cursor, line: Line, default) -> dict[str, Any] | None:
s = c.string() if c.text else None
if s is not None:
return self.displayable_expr(const(s), line)
for child in line.block:
cc = self.cursor(child)
s = cc.string()
if s is not None:
return self.displayable_expr(const(s), child)
m = _CALL_LINE.match(child.text)
if m and m.group(1) in _DISPLAYABLE_CALLS:
e = self.expr(child.text, child)
return self.displayable_expr(e, child) if e else None
return default
def st_camera(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("camera")
c.word() if not c.peek("at") and not c.peek(":") else None
node: Node = {"op": "camera", "at": []}
if c.keyword("at"):
for item in _split_top(c.until(set(), ":")):
node["at"].append(self.transform_ref(item, line))
props: dict[str, Any] = {}
for child in line.block:
props.update(_atl_props(child.text))
if props:
node["at"].append({"props": props})
return [node]
def st_transform(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("transform")
name = c.word()
if name:
issues: list[tuple[str, str]] = []
uses: set[str] = set()
props, animation = self.atl_spec(line.block, issues.append, uses)
params = self.transform_params(line)
if params: # parameters are read as run-time values, so the timeline is always needed
animation = animation or {"steps": [{"op": "set", "props": props}] if props else [], "repeat": None}
animation["params"] = params
self.d.transforms[name] = props
if animation:
self.d.atl[name] = animation
self.d.atl_pending[name] = issues
self.d.atl_uses[name] = uses
if name in self.d.atl_referenced:
self.use_transform(name)
return []
def transform_params(self, line: Line) -> list[dict[str, Any]]:
match = re.fullmatch(r"transform\s+[A-Za-z_]\w*\s*\((.*)\)\s*:?", line.text.strip(), re.S)
if not match:
return []
try:
return pyexpr.params(ast.parse(f"def f({match.group(1)}): pass").body[0].args)
except (SyntaxError, Unsupported):
self.d.warn("Не разобраны параметры transform", self.where(line))
return []
def use_transform(self, name: str) -> None:
"""A transform is used by the game: its unsupported constructs now block the import."""
self.d.atl_referenced.add(name)
pending = self.d.atl_pending.pop(name, [])
for message, where in pending:
self.d.warn(message, where)
for other in self.d.atl_uses.get(name, ()):
self.use_transform(other)
def atl_spec(self, lines: list[Line], collect=None, uses=None) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""Parse an ATL block: (properties to start with, timeline or None when it is static)."""
def warn(message: str, line: Line) -> None:
if collect is None:
self.d.warn(message, self.where(line))
else:
collect((message, self.where(line)))
def on_use(name: str) -> None:
if uses is None:
self.use_transform(name)
else:
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,
lambda line: self.atl_image_line(line))
block = parser.block(lines)
static = static_props(block)
if static is not None:
return static, None
return initial_props(block), block
def static_atl(self, line: Line) -> dict[str, Any]:
props = _atl_props(line.text)
first = Cursor(line.text).peek_word()
if first in ("subpixel", "animation") and not line.block:
return {} # no visible effect in the browser
if first not in POSITION_PROPS or not props or line.block:
self.d.warn(f"Не поддерживается динамическая ATL-трансформация: {first or line.text}", self.where(line))
return props
# visual statements -------------------------------------------------------------
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
if c.keyword("expression") or c.keyword("image"):
source = c.until(_SHOW_KEYWORDS, ":")
e = self.expr(source, line)
return {"expr": e, "tag": source}
parts: list[str] = []
while True:
save = c.pos
p = c.image_part()
if p is None:
break
if p in _SHOW_KEYWORDS:
c.pos = save
break
parts.append(p)
if not parts:
raise c.error("ожидалось имя изображения")
return {"name": parts}
def show_clauses(self, c: Cursor, line: Line, node: Node) -> None:
node.setdefault("at", [])
node.setdefault("with", None)
if (node.get("img") or {}).get("tag"):
node.setdefault("as", node["img"]["tag"])
while not c.eol():
if c.keyword("as"):
node["as"] = c.image_part()
elif c.keyword("at"):
for item in _split_top(c.until(_SHOW_KEYWORDS - {"at"}, ":")):
node["at"].append(self.transform_ref(item, line))
elif c.keyword("behind"):
node["behind"] = [b.strip() for b in c.until(_SHOW_KEYWORDS, ":").split(",")]
elif c.keyword("onlayer"):
node["layer"] = c.word()
elif c.keyword("zorder"):
node["zorder"] = self.expr(c.until(_SHOW_KEYWORDS, ":"), line)
elif c.keyword("with"):
node["with"] = self.transition(c.until(set(), ":"), line)
elif c.char(":"):
props, animation = self.atl_spec(line.block)
if animation:
node["at"].append({"props": props, "atl": animation})
elif props:
node["at"].append({"props": props})
else:
self.d.warn("Не разобран фрагмент show/scene", f"{self.where(line)}: {c.rest()}")
break
def transform_ref(self, source: str, line: Line) -> dict[str, Any]:
source = source.strip()
if source in POSITIONS:
return {"pos": source}
if source.isidentifier():
self.use_transform(source)
if source in self.d.transforms:
spec = {"props": self.d.transforms[source], "name": source}
if source in self.d.atl:
spec["atl"] = source
return spec
e = self.expr(source, line)
if e and e["k"] == "call":
fname = _dotted_name(e["f"])
if fname in ("Position", "Transform"):
return {"props": _static_props(e["kw"])}
if fname == "Move":
moved = self.move_transform(e)
if moved:
return moved
if fname in ("Shake", "SnoopyShake"):
args = [_const(a) for a in e["a"]]
return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5,
"dist": _const(e["kw"].get("dist")) or 20}
if fname in self.d.transforms or fname in self.d.atl:
self.use_transform(fname)
spec = {"props": self.d.transforms.get(fname, {}), "name": fname}
if fname in self.d.atl:
spec["atl"] = fname
spec["args"], spec["kw"] = e["a"], e["kw"]
return spec
if "." not in fname and fname[:1].islower() and fname not in KNOWN_FACTORIES:
# parameterised transform defined later (or in another file); the player resolves it by name
self.use_transform(fname)
return {"name": fname, "atl": fname, "args": e["a"], "kw": e["kw"]}
if e and e["k"] == "name":
self.use_transform(e["id"])
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
# transform defined later in another file; the player resolves it by name
return {"name": e["id"]}
self.d.warn("Неизвестная трансформация", f"{self.where(line)}: {source}")
return {"name": source}
def move_transform(self, call: Expr) -> dict[str, Any] | None:
"""``Move((x0, y0), (x1, y1), seconds)``: slide the picture between two positions."""
args = [_const(a) for a in call["a"]]
kw = {k: _const(v) for k, v in call["kw"].items()}
start, end, seconds = (args + [None] * 3)[:3]
start, end = start or kw.get("pos1"), end or kw.get("pos2")
seconds = seconds if seconds is not None else kw.get("delay")
if not (isinstance(start, (list, tuple)) and isinstance(end, (list, tuple))
and len(start) == len(end) == 2 and isinstance(seconds, (int, float))):
return None
block = {"steps": [
{"op": "set", "props": {"xpos": start[0], "ypos": start[1]}},
{"op": "move", "warp": "linear", "t": float(seconds), "props": {"xpos": end[0], "ypos": end[1]}},
], "repeat": None}
return {"props": {"xpos": start[0], "ypos": start[1]}, "atl": block}
def screen_parser(self, collect=None) -> ScreenParser:
def warn(message: str, line: Line) -> None:
if collect is None:
self.d.warn(message, self.where(line))
else:
collect.append((message, self.where(line)))
return ScreenParser(warn, lambda source, line: self.transform_ref(source, line),
lambda lines: self.atl_spec(lines, collect.append if collect is not None else None, set()))
def st_screen(self, line: Line) -> list[Node]:
issues: list[tuple[str, str]] = []
parsed = self.screen_parser(issues).screen(line)
if parsed:
name, definition = parsed
self.d.screens[name] = definition # a later definition replaces an earlier one
self.d.screen_pending[name] = issues
if name in self.d.screen_used:
self.use_screen(name)
return []
def use_screen(self, name: str) -> None:
self.d.use_screen(name)
def st_style(self, line: Line) -> list[Node]:
match = re.fullmatch(r"style\s+([A-Za-z_]\w*)(?:\s+is\s+([A-Za-z_]\w*))?\s*:?", line.text.strip())
if not match:
return [] # `style x take y`, `style x clear`: nothing a browser style needs
name, parent = match.groups()
parser = self.screen_parser([])
props: dict[str, Any] = {}
for child in line.block:
props.update(parser.properties(tokens(child.text), child, lenient=True))
entry = self.d.styles.setdefault(name, {"is": parent, "props": {}})
entry["is"] = parent or entry["is"]
entry["props"].update(props)
return []
def screen_call(self, c: Cursor, line: Line) -> Node | None:
"""``name(args)`` after `call/show screen`; trailing `nopredict` and `with` are ignored."""
source = re.split(r"\s+(?:nopredict|with)\b", c.rest(), maxsplit=1)[0].strip()
call = self.expr(source, line) if source else None
if call is None:
self.d.warn("Не разобран вызов screen", self.where(line))
return None
if call["k"] == "name":
name, args, kw = call["id"], [], {}
elif call["k"] == "call" and call["f"]["k"] == "name":
name, args, kw = call["f"]["id"], call["a"], call["kw"]
else:
self.d.warn("Не разобран вызов screen", self.where(line))
return None
self.use_screen(name)
return {"name": name, "args": args, "kw": kw}
def st_scene(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("scene")
node: Node = {"op": "scene", "img": None}
if c.keyword("onlayer"):
node["layer"] = c.word()
if not c.eol() and not c.peek("with") and not c.peek("at"):
node["img"] = self.image_spec(c, line)
self.show_clauses(c, line, node)
self.d.stats["scene"] += 1
return [node]
def st_show(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("show")
if c.keyword("screen"):
node = self.screen_call(c, line)
return [{"op": "show_screen", **node}] if node else []
if c.keyword("text"):
text = c.string()
if text is not None:
node: Node = {"op": "show", "img": {"text": text}}
else:
node = {"op": "show", "img": {"textExpr": self.expr(c.until(_SHOW_KEYWORDS, ":"), line,
const(""))}}
self.show_clauses(c, line, node)
return [node]
node = {"op": "show", "img": self.image_spec(c, line)}
self.show_clauses(c, line, node)
self.d.stats["show"] += 1
return [node]
def st_hide(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("hide")
if c.keyword("screen"):
name = c.word()
if not name:
return []
return [{"op": "hide_screen", "name": name}] # hiding a screen that does not exist is harmless
spec = self.image_spec(c, line)
node: Node = {"op": "hide", "tag": spec.get("tag", spec.get("name", [""])[0])}
self.show_clauses(c, line, node)
if node.get("as"):
node["tag"] = node.pop("as")
node.pop("at", None)
return [node]
def st_with(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("with")
return [{"op": "with", "t": self.transition(c.rest(), line)}]
def transition(self, source: str, line: Line) -> dict[str, Any] | None:
source = source.strip()
if source in ("None", ""):
return None
if source in self.d.transitions:
return self.d.transitions[source]
if source in TRANSITIONS:
return TRANSITIONS[source]
e = self.expr(source, line)
return self.transition_expr(e) if e else TRANSITIONS["dissolve"]
def transition_expr(self, e: Expr) -> dict[str, Any] | None:
if e["k"] == "const" and e["v"] is None:
return None
if e["k"] == "name":
return self.d.transitions.get(e["id"]) or TRANSITIONS.get(e["id"]) or {
"t": "ref", "name": e["id"]}
if e["k"] == "call":
fname = _dotted_name(e["f"])
args = [_const(a) for a in e["a"]]
kw = {k: _const(v) for k, v in e["kw"].items()}
nums = [a for a in args if isinstance(a, (int, float))]
if fname == "Fade":
out = nums[0] if nums else 0.5
hold = nums[1] if len(nums) > 2 else 0.0
fin = nums[2] if len(nums) > 2 else (nums[1] if len(nums) > 1 else out)
return {"t": "fade", "out": out, "hold": hold, "in": fin,
"color": kw.get("color") or "#000000"}
if fname in ("Shake", "SnoopyShake"):
return {"t": "punch", "dir": "h", "d": nums[0] if nums else 0.5}
if fname in ("MoveTransition", "Move"):
return {"t": "move", "d": nums[0] if nums else 0.5}
if fname in TRANSITIONS:
return TRANSITIONS[fname]
return {"t": "dissolve", "d": nums[0] if nums else kw.get("time", 0.5) or 0.5}
return TRANSITIONS["dissolve"]
# audio ------------------------------------------------------------------------
def audio_files(self, c: Cursor, line: Line) -> list[Expr]:
src = c.until({"fadein", "fadeout", "loop", "noloop", "if_changed", "volume", "channel"})
e = self.expr(src, line)
if e is None:
return []
if e["k"] in ("list", "tuple"):
return e["v"]
return [e]
def st_play(self, line: Line, op: str = "play") -> list[Node]:
c = self.cursor(line)
c.keyword(op)
ch = c.word() or "music"
node: Node = {"op": op, "ch": ch, "files": self.audio_files(c, line)}
while not c.eol():
if c.keyword("fadein"):
node["fadein"] = _num(c.number())
elif c.keyword("fadeout"):
node["fadeout"] = _num(c.number())
elif c.keyword("loop"):
node["loop"] = True
elif c.keyword("noloop"):
node["loop"] = False
elif c.keyword("if_changed"):
node["ifChanged"] = True
elif c.keyword("volume"):
node["volume"] = _num(c.number())
else:
c.rest()
self.d.stats["audio"] += 1
return [node]
def st_queue(self, line: Line) -> list[Node]:
return self.st_play(line, "queue")
def st_stop(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("stop")
node: Node = {"op": "stop", "ch": c.word() or "music"}
if c.keyword("fadeout"):
node["fadeout"] = _num(c.number())
return [node]
def st_voice(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("voice")
if c.keyword("sustain"):
return [{"op": "voice", "sustain": True}]
e = self.expr(c.rest(), line)
return [{"op": "voice", "file": e}] if e else []
# flow ---------------------------------------------------------------------------
def st_jump(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("jump")
if c.keyword("expression"):
return [{"op": "jump", "expr": self.expr(c.rest(), line, const(""))}]
return [{"op": "jump", "target": self.label_ref(c.label_name() or "")}]
def st_call(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("call")
if c.keyword("screen"):
node = self.screen_call(c, line)
return [{"op": "call_screen", **node}] if node else []
if c.keyword("expression"):
node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line,
const(""))}
c.keyword("pass")
else:
node = {"op": "call", "target": self.label_ref(c.label_name() or "")}
args = self.call_args(c, line)
if args:
node["args"] = args
return [node]
def st_return(self, line: Line) -> list[Node]:
return [{"op": "return"}]
def st_pass(self, line: Line) -> list[Node]:
return []
def st_pause(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("pause")
rest = c.rest()
return [{"op": "pause", "d": self.expr(rest, line) if rest else None}]
def st_while(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("while")
cond = c.rest().removesuffix(":").strip()
return [{"op": "while", "c": self.expr(cond, line, const(False)),
"body": self.block(line.block)}]
def st_if(self, line: Line) -> list[Node]:
branches, _ = self.if_chain([line], 0)
return [{"op": "if", "branches": branches}]
def cds_random(self, line: Line) -> list[Node]:
"""``random:`` (cookbook recipe): run one of the statements below, by weight (`weight 2.0 "text"`)."""
options: list[tuple[float, list[Node]]] = []
for child in line.block:
weight, text = 1.0, child.text
if match := re.match(r"weight\s+([\d.]+)\s*(.*)$", text, re.S):
weight, text = float(match[1]), match[2]
options.append((weight, self.block([replace(child, text=text)])))
if not options:
return []
self.d.random_counter += 1
var = f"_random_{self.d.random_counter}"
total = sum(w for w, _ in options)
pick = pyexpr.parse_stmts(f"{var} = renpy.random.random() * {total!r}")
branches, bound = [], 0.0
for index, (weight, body) in enumerate(options):
bound += weight
cond = None if index == len(options) - 1 else pyexpr.parse_expr(f"{var} <= {bound!r}")
branches.append({"c": cond, "body": body})
return [{"op": "exec", "code": pick}, {"op": "if", "branches": branches}]
def cds_block(self, line: Line) -> list[Node]:
"""``block:`` (cookbook recipe): its statements run in place."""
return self.block(line.block)
def st_window(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("window")
mode = c.word()
return [{"op": "window", "mode": mode}] if mode in ("show", "hide") else []
def st_nvl(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("nvl")
action = c.word()
return [{"op": "nvl", "a": action}] if action in ("clear", "show", "hide") else []
def st_menu(self, line: Line) -> list[Node]:
node: Node = {"op": "menu", "items": [], "say": None}
head = self.cursor(line)
head.keyword("menu")
name = head.label_name()
out: list[Node] = []
if name:
out.append({"op": "label", "name": self.label_ref(name)})
for child in line.block:
c = self.cursor(child)
if c.keyword("set"):
node["set"] = self.expr(c.rest(), child)
continue
if c.keyword("with"):
continue
text = c.string()
if text is not None and child.text.rstrip().endswith(":"):
cond = None
if c.keyword("if"):
cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True))
elif c.peek("("):
c.until({"if"}, ":")
if c.keyword("if"):
cond = self.expr(c.rest().removesuffix(":").strip(), child, const(True))
node["items"].append({"text": text, "if": cond, "body": self.block(child.block)})
continue
say = self.say(child)
if say:
node["say"] = say[0]
else:
self.d.warn("Не разобран пункт меню", f"{self.where(child)}: {child.text[:60]}")
self.d.stats["menu"] += 1
return out + [node]
# say ------------------------------------------------------------------------------
def say(self, line: Line) -> list[Node] | None:
c = self.cursor(line)
who: str | None = None
who_str: str | None = None
attrs: list[str] = []
first = c.string()
if first is not None:
second = c.string() if not c.eol() else None
if second is not None:
who_str, what = first, second
else:
what = first
else:
who = c.dotted()
if who is None:
return None
while True:
save = c.pos
if c.string_raw() is not None:
c.pos = save
break
p = c.image_part() or ("@" if c.char("@") else None)
if p is None:
c.pos = save
break
if p != "@":
attrs.append(p)
what = c.string()
if what is None:
return None
node: Node = {"op": "say", "what": what, "_ln": line.lineno} # _ln: dropped after IDs
if who and who != "narrator":
node["who"] = who
if who_str is not None:
node["whoStr"] = who_str
if attrs:
node["attrs"] = attrs
while not c.eol():
if c.keyword("with"):
node["with"] = self.transition(c.rest(), line)
elif c.keyword("nointeract"):
node["interact"] = False
elif c.keyword("id"):
c.word()
elif c.char("("):
c.pos -= 1
c.until({"with", "nointeract", "id"})
else:
c.rest()
self.d.stats["say"] += 1
return [node]