`init -490 screen`, `import x as y` and `from m import A as B`, classes with @renpy.pure, pickle hooks
on classes, `*` items in tuples and lists, generator functions (yield statements; the player runs them
to the end), multi-line expressions that need brackets, Null() and Animation() images, images given as
expressions (`"path/{}.png".format(x)`, f-strings), window statements in init, `offset` in ATL, input
widgets with their values and the focus/confirm actions, `continue` and `break` in screens, `drag`
and DiamondBox-style `fixed` with cell_size, `getattr(...)()` calls, transforms called with arguments,
and the whitelist for the new runtime. JSON files of a game are shipped as data files. Also fixes
blocking_names failing on a call of a call during full conversion.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1716 lines
78 KiB
Python
1716 lines
78 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
|
|
|
|
|
|
LAZY_IMAGE_KINDS = {"fmt", "bin", "name", "attr", "sub", "if"}
|
|
LIVE2D_IF = re.compile(r"^\s*if\s+renpy\.has_live2d\(\)(?:\s+and\s.*)?:\s*$")
|
|
TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""")
|
|
|
|
|
|
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.call_in_new_context" and len(args) == 1 and not kw and args[0].get("k") == "const" and args[0]["v"] == "_hide_windows":
|
|
# Ren'Py's own label: hides the dialogue window until the player clicks
|
|
return [{"op": "pause", "d": None}]
|
|
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 []
|
|
rest = c.rest().strip()
|
|
if rest and rest != ":": # `init -490 screen name(...):` - one statement with a priority in front
|
|
return self.statement(replace(line, text=rest), top_level=True)
|
|
for child in self.fold_live2d(line.block):
|
|
for node in self.statement(child, top_level=True):
|
|
if node["op"] == "exec":
|
|
self.d.init_code.extend(node["code"])
|
|
elif node["op"] != "window": # `window hide` at init time changes nothing that is shown
|
|
self.d.warn("Оператор сцены внутри init", self.where(child))
|
|
return []
|
|
|
|
@staticmethod
|
|
def fold_live2d(children: list[Line]) -> list[Line]:
|
|
"""`if renpy.has_live2d() and ...:` is false in the player (it has no Live2D), so only its `else` counts."""
|
|
out: list[Line] = []
|
|
i = 0
|
|
while i < len(children):
|
|
child = children[i]
|
|
if LIVE2D_IF.match(child.text):
|
|
i += 1
|
|
if i < len(children) and children[i].text.strip().startswith("else"):
|
|
out.extend(RpyParser.fold_live2d(children[i].block))
|
|
i += 1
|
|
continue
|
|
out.append(child)
|
|
i += 1
|
|
return out
|
|
|
|
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":
|
|
# a name, an f-string or a sum of strings: the picture is whatever it evaluates to when it is shown
|
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=e["k"] not in LAZY_IMAGE_KINDS)
|
|
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 == "Null": # nothing to draw: a transparent picture
|
|
return {"color": "#00000000"}
|
|
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}
|
|
if fname == "Animation": # pictures that take turns, named by values known only while playing
|
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
|
return {"expr": e}
|
|
# 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
|
|
if fname == "Animation" or e["f"].get("k") == "attr" and not fname.startswith(("im.", "renpy.")):
|
|
# `Animation(...)` and `"path/{}.png".format(x)`: built from values known only while playing
|
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
|
return {"expr": e}
|
|
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() or fname in self.d.transform_names) 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()
|
|
# `onlayer name` only picks the stacking layer; the player keeps every screen in one layer
|
|
source = re.sub(r"\s+onlayer\s+\w+\s*$", "", source)
|
|
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:
|
|
# `__("Girl") "Hello"`: a translatable speaker name; the name is looked up by its text anyway
|
|
if wrapped := TRANSLATED_SPEAKER.match(line.text):
|
|
line = replace(line, text=wrapped[1] + line.text[wrapped.end():])
|
|
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]
|