Python sandbox - try/except/else/finally, raise (incl. re-raise), assert, for/while ... else - Multi-generator and dict comprehensions - Builtin exception hierarchy; user exceptions subclass it - Inside try, missing keys/indexes, division by zero, attribute access on None and unknown names raise Python exceptions; lookups stay lenient elsewhere - int()/float() raise ValueError on non-numeric strings ATL - converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat, parallel, choice, block, on show/hide and named transforms become a timeline shipped as game.json "atl"; Move() becomes a linear slide - player/atl.ts: timeline runner wired into the renderer; `on hide` plays before the picture is removed - function, time, contains, matrixcolor and expressions still block the import Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README and ARCHITECTURE updated. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
1188 lines
50 KiB
Python
1188 lines
50 KiB
Python
"""Ren'Py script (.rpy) → PGVN IR."""
|
|
|
|
import ast
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from app.converter import pyexpr
|
|
from app.converter.atl import 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,
|
|
_dedent,
|
|
_dotted_name,
|
|
_jump_or_call,
|
|
_num,
|
|
_parse_py,
|
|
_split_top,
|
|
_static_props,
|
|
)
|
|
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"}
|
|
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)
|
|
atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name
|
|
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)
|
|
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)
|
|
|
|
def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
|
|
self.warnings[kind] += 1
|
|
if blocking:
|
|
self.blockers[kind] += 1
|
|
if where:
|
|
self.blocker_examples.setdefault(kind, where)
|
|
if where and kind not in self.warning_examples:
|
|
self.warning_examples[kind] = where
|
|
|
|
|
|
class RpyParser:
|
|
def __init__(self, data: ScriptData, filename: str):
|
|
self.d = data
|
|
self.filename = filename
|
|
self.global_label = ""
|
|
|
|
def label_ref(self, name: str) -> str:
|
|
"""Resolve `.local` label names against the enclosing global label."""
|
|
return self.global_label + name if name.startswith(".") else name
|
|
|
|
# --- helpers -------------------------------------------------------------
|
|
|
|
def where(self, line: Line) -> str:
|
|
return f"{self.filename}:{line.lineno}"
|
|
|
|
def cursor(self, line: Line, text: str | None = None) -> Cursor:
|
|
return Cursor(line.text if text is None else text, self.filename, line.lineno)
|
|
|
|
def expr(self, source: str, line: Line, default: Expr | None = None) -> Expr | None:
|
|
try:
|
|
return pyexpr.parse_expr(source)
|
|
except Unsupported as e:
|
|
self.d.warn(f"Выражение: {e}", self.where(line))
|
|
return default
|
|
|
|
def python(self, source: str, line: Line) -> list[Stmt]:
|
|
"""Translate python code statement by statement, skipping unsupported ones."""
|
|
try:
|
|
tree = _parse_py(source)
|
|
except SyntaxError as e:
|
|
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
|
|
return []
|
|
out: list[Stmt] = []
|
|
for node in tree.body:
|
|
try:
|
|
out.extend(pyexpr.stmt(node))
|
|
except Unsupported as e:
|
|
self.d.warn(f"Python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
|
|
return out
|
|
|
|
# --- top level -------------------------------------------------------------
|
|
|
|
def parse(self, source: str) -> None:
|
|
tree = parse_tree(source, self.filename)
|
|
labels: list[Node] = []
|
|
current: Node | None = None
|
|
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),
|
|
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]:
|
|
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
|
|
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:
|
|
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 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))
|
|
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:
|
|
props, animation = self.atl_spec(line.block)
|
|
self.d.transforms[name] = props
|
|
if animation:
|
|
self.d.atl[name] = animation
|
|
return []
|
|
|
|
def atl_spec(self, lines: list[Line]) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
|
"""Parse an ATL block: (properties to start with, timeline or None when it is static)."""
|
|
parser = AtlParser(lambda message, line: self.d.warn(message, self.where(line)),
|
|
lambda name: self.d.transforms.get(name) if name not in self.d.atl
|
|
and name in self.d.transforms else None)
|
|
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 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"):
|
|
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, 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 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:
|
|
spec = {"props": self.d.transforms[fname], "name": fname}
|
|
if fname in self.d.atl:
|
|
spec["atl"] = fname
|
|
return spec
|
|
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 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]
|