- every script node gets a permanent id, say nodes a Ren'Py-style tid - all languages from the game's tl/ are imported at conversion: dialogue blocks matched by their source-location comment or by label + original text, `translate strings` become a per-language strings table - source language from config.language or detected from the dialogue - manifest stores locales/<lang>/<module>.json and strings.json; game.json lists sourceLang and available locales with progress - player: language picker in main menu and settings, fallback to source, __(), [var!t] and _preferences.language - parser: `default` with init priority, and one bad statement no longer drops the whole file - docs/LOCALIZATION.md: design for translations, publishing and mods Line endings of several converter/player files go back to LF: an earlier commit accidentally stored them as CRLF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1230 lines
50 KiB
Python
1230 lines
50 KiB
Python
"""Ren'Py script (.rpy) → PGVN IR."""
|
|
|
|
import ast
|
|
import re
|
|
import warnings
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from app.converter import pyexpr
|
|
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
|
|
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
|
|
|
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"}
|
|
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
|
|
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
|
|
"align", "pos", "anchor", "xysize", "size"}
|
|
CHANNELS = {"music", "sound", "voice", "audio", "movie"}
|
|
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
|
|
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)
|
|
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)
|
|
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
|
|
warnings: Counter = field(default_factory=Counter)
|
|
warning_examples: dict[str, str] = field(default_factory=dict)
|
|
stats: Counter = field(default_factory=Counter)
|
|
|
|
def warn(self, kind: str, where: str = "") -> None:
|
|
self.warnings[kind] += 1
|
|
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
|
|
for line in tree:
|
|
c = self.cursor(line)
|
|
first = c.peek_word()
|
|
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
|
|
nodes = self.statement(line, top_level=True)
|
|
if nodes and current is not None:
|
|
current["body"].extend(nodes)
|
|
elif nodes:
|
|
# statements before any label run on fall-through into the first label
|
|
labels.append(current := {"name": f"_{self.filename}_{line.lineno}",
|
|
"body": nodes})
|
|
self.d.modules[self.filename] = labels
|
|
|
|
def block(self, lines: list[Line]) -> 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))
|
|
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 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))
|
|
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]:
|
|
stmts = self.python(code, line)
|
|
if len(stmts) == 1 and stmts[0]["op"] == "eval":
|
|
special = self.renpy_call(stmts[0]["e"], line)
|
|
if special is not None:
|
|
return special
|
|
return [{"op": "exec", "code": stmts}] if stmts else []
|
|
|
|
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 and args[0].get("k") == "const":
|
|
return [{"op": "show", "img": {"name": str(args[0]["v"]).split()}, "at": [],
|
|
"with": None}]
|
|
if fname == "renpy.hide" and args and args[0].get("k") == "const":
|
|
return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}]
|
|
if fname == "renpy.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
|
|
|
|
# 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 []
|
|
code = self.python(line.block_source(), line)
|
|
return [{"op": "exec", "code": code}] if code else []
|
|
|
|
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
|
|
for node in tree.body:
|
|
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(_dedent(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
|
|
try:
|
|
self.d.init_code.extend(pyexpr.stmt(node))
|
|
except Unsupported as e:
|
|
self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
|
|
|
|
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 != "=":
|
|
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:
|
|
(self.d.config if head == "config" else self.d.gui)[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)
|
|
if c.char("="):
|
|
img = self.displayable(c.rest(), line)
|
|
elif c.char(":"):
|
|
img = self.atl_image(line)
|
|
else:
|
|
raise c.error("ожидалось = или :")
|
|
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 in ("Movie",):
|
|
play = kw.get("play") or (args[0] if args else None)
|
|
return {"movie": _const(play)} if play else None
|
|
if fname == "ConditionSwitch":
|
|
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 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 atl_image(self, line: Line) -> dict[str, Any] | None:
|
|
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 not frames or word == "block":
|
|
repeat = self.atl_frames(child.block, frames, props) or repeat
|
|
continue
|
|
if word == "repeat":
|
|
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"):
|
|
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))
|
|
frames.append({"img": {"expr": e}})
|
|
continue
|
|
if not frames or not frames[-1].get("d"):
|
|
props.update(_atl_props(child.text))
|
|
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()
|
|
props: dict[str, Any] = {}
|
|
for child in line.block:
|
|
props.update(_atl_props(child.text))
|
|
if name:
|
|
self.d.transforms[name] = props
|
|
return []
|
|
|
|
# visual statements -------------------------------------------------------------
|
|
|
|
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
|
|
if c.keyword("expression"):
|
|
e = self.expr(c.until(_SHOW_KEYWORDS), line)
|
|
return {"expr": e}
|
|
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)
|
|
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: dict[str, Any] = {}
|
|
for child in line.block:
|
|
props.update(_atl_props(child.text))
|
|
if 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 in self.d.transforms:
|
|
return {"props": self.d.transforms[source], "name": source}
|
|
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 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:
|
|
return {"props": self.d.transforms[fname], "name": fname}
|
|
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 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"):
|
|
self.d.warn("Не поддерживается: screen", self.where(line))
|
|
return []
|
|
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"):
|
|
return []
|
|
spec = self.image_spec(c, line)
|
|
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
|
|
self.show_clauses(c, line, node)
|
|
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"):
|
|
self.d.warn("Не поддерживается: call screen", self.where(line))
|
|
return []
|
|
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 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]
|
|
|
|
|
|
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
|
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
|
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
|
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
|
"ConditionSwitch", "Image", "im.Scale", "im.FactorScale", "im.Flip",
|
|
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
|
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
|
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
|
|
|
|
|
|
def _dedent(source: str) -> str:
|
|
import textwrap
|
|
return textwrap.dedent(source)
|
|
|
|
|
|
def _parse_py(source: str, mode: str = "exec") -> ast.AST:
|
|
# decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore")
|
|
return ast.parse(_dedent(source), mode=mode)
|
|
|
|
|
|
def _dotted_name(e: Expr) -> str:
|
|
if e["k"] == "name":
|
|
return e["id"]
|
|
if e["k"] == "attr":
|
|
return f"{_dotted_name(e['o'])}.{e['a']}"
|
|
return ""
|
|
|
|
|
|
def _assign_name(t: ast.AST) -> str | None:
|
|
if isinstance(t, ast.Name):
|
|
return t.id
|
|
if isinstance(t, ast.Attribute):
|
|
inner = _assign_name(t.value)
|
|
return f"{inner}.{t.attr}" if inner else None
|
|
return None
|
|
|
|
|
|
def _const(e: Expr | None) -> Any:
|
|
if e is None:
|
|
return None
|
|
if e.get("k") == "const":
|
|
return e["v"]
|
|
if e.get("k") in ("tuple", "list"):
|
|
return [_const(x) for x in e["v"]]
|
|
if e.get("k") == "unary" and e["op"] == "-":
|
|
v = _const(e["v"])
|
|
return -v if isinstance(v, (int, float)) else None
|
|
return None
|
|
|
|
|
|
def _num(s: str | None) -> float | None:
|
|
try:
|
|
return float(s) if s is not None else None
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _static_props(kw: dict[str, Expr]) -> dict[str, Any]:
|
|
props = {}
|
|
for k, v in kw.items():
|
|
if k in POSITION_PROPS:
|
|
val = _const(v)
|
|
if val is not None:
|
|
props[k] = val
|
|
return props
|
|
|
|
|
|
def _atl_props(text: str) -> dict[str, Any]:
|
|
"""Extract static `prop value` pairs from one ATL line, ignoring timing."""
|
|
tokens = text.replace(",", " ").split()
|
|
props: dict[str, Any] = {}
|
|
i = 0
|
|
while i < len(tokens) - 1:
|
|
key = tokens[i]
|
|
if key in POSITION_PROPS:
|
|
raw = tokens[i + 1]
|
|
try:
|
|
props[key] = ast.literal_eval(raw)
|
|
except (ValueError, SyntaxError):
|
|
if raw.startswith("(") and i + 2 < len(tokens):
|
|
try:
|
|
props[key] = list(ast.literal_eval(raw + "," + tokens[i + 2]))
|
|
i += 1
|
|
except (ValueError, SyntaxError):
|
|
pass
|
|
i += 2
|
|
else:
|
|
i += 1
|
|
return props
|
|
|
|
|
|
def _split_top(source: str) -> list[str]:
|
|
parts, depth, cur = [], 0, []
|
|
for ch in source:
|
|
if ch in "([{":
|
|
depth += 1
|
|
elif ch in ")]}":
|
|
depth -= 1
|
|
if ch == "," and depth == 0:
|
|
parts.append("".join(cur))
|
|
cur = []
|
|
else:
|
|
cur.append(ch)
|
|
if "".join(cur).strip():
|
|
parts.append("".join(cur))
|
|
return [p.strip() for p in parts if p.strip()]
|
|
|
|
|
|
def _jump_or_call(op: str, target: Expr) -> Node:
|
|
if target.get("k") == "const":
|
|
return {"op": op, "target": target["v"]}
|
|
return {"op": op, "expr": target}
|