feat: Ren'Py screens (screen/call screen/show screen) with actions and styles
Converter - screens.py parses the screen language: containers (fixed, frame, vbox, hbox, grid, vpgrid, viewport, button, has), widgets (text, textbutton, imagebutton, add, bar ...), if/for/use/transclude, default, python, key, timer, on, inline ATL, conditional properties, and decompiled forms (unparenthesised operators, glued property values, comma separated actions) - style statements, call/show/hide screen operators and Python-level renpy.show_screen are translated; only screens the game shows are audited, unused stock screens no longer block an import; undefined screens do - ATL values may be computed at run time and transforms take parameters (at blink(0.2, 0.3)); blur property Player - screens.ts builds the DOM from the IR and rebuilds it on state changes; buttons with idle/hover/selected/insensitive states, tooltips, sounds, modal screens, timers and keys that survive re-rendering, saved with the save file - Actions: Jump, Call, Return, SetVariable, Toggle*, SetScreenVariable, Show, Hide, ToggleScreen, Function, Play, Notify, If, Language, ShowMenu, OpenURL ... - Crop/LiveCrop/Solid/Frame/Transform/DynamicDisplayable values, crop property, globals(), renpy.variant/change_language/seen_image, transition stubs Tests: screen parser and runtime (linkedom DOM), screens fixture compiled from tests/fixtures/screens.rpy. Docs: docs/SCREENS.md. Twisted-Thoughts now passes the strict compatibility check. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47fc121e7d
commit
62ece25902
24 files changed
+2997
-60
No files matched your search
@@ -11,6 +11,9 @@ A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps:
|
||||
* ``{"op": "on", "events": [...], "block": block}``
|
||||
* ``{"op": "use", "name": transform}``
|
||||
|
||||
A number or property value that is not a literal becomes ``{"e": expr}`` and is evaluated when the
|
||||
timeline runs, with the transform's parameters in scope (``transform blink(delay, cycle):``).
|
||||
|
||||
Everything else (``function``, ``contains``, ``time``, expressions, shaders, matrix
|
||||
colours) is reported through ``warn`` so the game is not imported half-working.
|
||||
"""
|
||||
@@ -19,6 +22,7 @@ import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.converter import pyexpr
|
||||
from app.converter.rpy_helpers import POSITION_PROPS
|
||||
from app.converter.rpy_lexer import Line
|
||||
|
||||
@@ -69,12 +73,24 @@ class AtlParser:
|
||||
|
||||
# --- helpers -------------------------------------------------------------------
|
||||
|
||||
def number(self, raw: str, line: Line) -> float | None:
|
||||
def value(self, raw: str, line: Line) -> Any:
|
||||
"""A literal, or ``{"e": expr}`` for anything computed at run time."""
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
return ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
pass
|
||||
try:
|
||||
return {"e": pyexpr.parse_expr(raw)}
|
||||
except pyexpr.Unsupported:
|
||||
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
|
||||
return None
|
||||
|
||||
def number(self, raw: str, line: Line) -> Any:
|
||||
value = self.value(raw, line)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
self.warn(f"Не поддерживается ATL: значение {raw}", line)
|
||||
return None
|
||||
@@ -94,15 +110,10 @@ class AtlParser:
|
||||
if i + 1 >= len(parts):
|
||||
self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
|
||||
return out
|
||||
raw = parts[i + 1]
|
||||
try:
|
||||
value = ast.literal_eval(raw)
|
||||
except (ValueError, SyntaxError):
|
||||
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
|
||||
value = self.value(parts[i + 1], line)
|
||||
if value is None:
|
||||
return out
|
||||
if isinstance(value, tuple):
|
||||
value = list(value)
|
||||
out[key] = value
|
||||
out[key] = list(value) if isinstance(value, tuple) else value
|
||||
i += 2
|
||||
return out
|
||||
|
||||
@@ -123,7 +134,7 @@ class AtlParser:
|
||||
if first == "repeat":
|
||||
repeat = True if len(parts) == 1 else self.number(parts[1], line)
|
||||
if repeat is not True and repeat is not None:
|
||||
repeat = int(repeat)
|
||||
repeat = repeat if isinstance(repeat, dict) else int(repeat)
|
||||
elif first == "pause":
|
||||
seconds = self.number(parts[1], line) if len(parts) > 1 else None
|
||||
if seconds is not None:
|
||||
@@ -182,7 +193,7 @@ class AtlParser:
|
||||
words = tokens(current.text.rstrip(":").strip())
|
||||
if not words or words[0] != first:
|
||||
break
|
||||
weight = 1.0
|
||||
weight: Any = 1.0
|
||||
if first == "choice" and len(words) > 1:
|
||||
weight = self.number(words[1], current) or 1.0
|
||||
group.append((weight, self.block(current.block)))
|
||||
@@ -194,10 +205,18 @@ class AtlParser:
|
||||
return j
|
||||
|
||||
|
||||
def computed(value: Any) -> bool:
|
||||
if isinstance(value, dict):
|
||||
return "e" in value
|
||||
return isinstance(value, list) and any(computed(item) for item in value)
|
||||
|
||||
|
||||
def static_props(block: Block) -> dict[str, Any] | None:
|
||||
"""Merged properties when the block only assigns, else None (it needs the timeline)."""
|
||||
if block["repeat"] is not None or any(step["op"] != "set" for step in block["steps"]):
|
||||
return None
|
||||
if any(computed(v) for step in block["steps"] for v in step["props"].values()):
|
||||
return None
|
||||
merged: dict[str, Any] = {}
|
||||
for step in block["steps"]:
|
||||
merged.update(step["props"])
|
||||
@@ -210,5 +229,5 @@ def initial_props(block: Block) -> dict[str, Any]:
|
||||
for step in block["steps"]:
|
||||
if step["op"] != "set":
|
||||
break
|
||||
merged.update(step["props"])
|
||||
merged.update({k: v for k, v in step["props"].items() if not computed(v)})
|
||||
return merged
|
||||
@@ -105,6 +105,16 @@ CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
||||
for method in ("set_volume", "set_pause", "set_pan")}
|
||||
CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition"}
|
||||
CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"}
|
||||
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
|
||||
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
|
||||
SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullAction Play Stop Notify
|
||||
Quit MainMenu If GetTooltip StaticValue VariableValue AnimatedValue FieldValue SelectedIf
|
||||
SensitiveIf Queue ToggleScreen Language ShowMenu OpenURL Crop LiveCrop Solid Frame Null Transform
|
||||
DynamicDisplayable Color Dissolve Fade ImageDissolve MoveTransition CropMove PushMove Pixellate
|
||||
ComposeTransition With""".split())
|
||||
CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "renpy.seen_image",
|
||||
"renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction",
|
||||
"renpy.get_screen", "renpy.call_screen"}
|
||||
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
|
||||
|
||||
|
||||
@@ -139,6 +149,11 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
|
||||
target(value.get("t"))
|
||||
elif kind == "lambda":
|
||||
names.update(p["n"] for p in value["params"])
|
||||
elif value.get("c") == "for":
|
||||
target(value.get("t"))
|
||||
elif "params" in value and "defaults" in value and "body" in value: # a screen
|
||||
names.update(p["n"] for p in value["params"])
|
||||
names.update(name for name, _ in value["defaults"])
|
||||
elif kind == "comp":
|
||||
target(value.get("t"))
|
||||
for gen in value.get("gens", []):
|
||||
@@ -152,9 +167,42 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
|
||||
return names, methods
|
||||
|
||||
|
||||
SCREEN_OPENERS = {"renpy.show_screen", "renpy.call_screen", "renpy.hide_screen", "Show", "ShowTransient",
|
||||
"Hide"}
|
||||
|
||||
|
||||
def mark_screen_calls(data) -> None:
|
||||
"""`renpy.show_screen("x")` in game code uses screen x, just like `show screen x`."""
|
||||
def visit(value):
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
visit(item)
|
||||
elif isinstance(value, dict):
|
||||
if value.get("k") == "call" and value.get("a"):
|
||||
name = value["f"]["id"] if value["f"].get("k") == "name" else (
|
||||
"renpy." + value["f"]["a"] if value["f"].get("k") == "attr"
|
||||
and value["f"]["o"].get("k") == "name" and value["f"]["o"]["id"] == "renpy" else "")
|
||||
first = value["a"][0]
|
||||
if name in SCREEN_OPENERS and first.get("k") == "const" and isinstance(first["v"], str):
|
||||
if first["v"] in data.screens and first["v"] not in data.screen_used:
|
||||
data.use_screen(first["v"])
|
||||
changed.append(first["v"])
|
||||
for child in value.values():
|
||||
visit(child)
|
||||
|
||||
changed = [True]
|
||||
while changed:
|
||||
changed.clear()
|
||||
visit([data.modules, data.init_code, data.defaults,
|
||||
[data.screens[n] for n in data.screen_used if n in data.screens]])
|
||||
|
||||
|
||||
def audit_ir(data) -> None:
|
||||
"""Reject calls which the browser would otherwise silently replace by None."""
|
||||
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images])
|
||||
mark_screen_calls(data)
|
||||
used_screens = {n: data.screens[n] for n in data.screen_used if n in data.screens}
|
||||
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images,
|
||||
list(used_screens.values())])
|
||||
user_names |= {name for name, _ in data.defaults}
|
||||
|
||||
def dotted(value):
|
||||
@@ -189,5 +237,9 @@ def audit_ir(data) -> None:
|
||||
for path, module in data.modules.items():
|
||||
visit(module, path)
|
||||
visit(data.defaults, "default")
|
||||
for name, definition in used_screens.items():
|
||||
visit(definition, f"screen {name}")
|
||||
for name in sorted(data.screen_used - set(data.screens)):
|
||||
data.warn("Не найден screen", name)
|
||||
visit(data.init_code, "init")
|
||||
visit(data.images, "image")
|
||||
@@ -191,6 +191,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
||||
"images": images,
|
||||
"transforms": data.transforms,
|
||||
"atl": data.atl,
|
||||
"screens": {name: data.screens[name] for name in sorted(_used_screens(data))},
|
||||
"styles": data.styles,
|
||||
"transitions": data.transitions,
|
||||
"audio": audio,
|
||||
"channels": data.channels,
|
||||
@@ -297,6 +299,20 @@ def _size(path: Path) -> int:
|
||||
ENGINE_DIRS = ("cache", "saves", "tl", "renpy", "0x52-urm")
|
||||
|
||||
|
||||
def _used_screens(data: ScriptData) -> set[str]:
|
||||
"""Screens the game shows (and the ones those open); the rest stay out of game.json."""
|
||||
from app.converter.screens import referenced_screens
|
||||
|
||||
used = {name for name in data.screen_used if name in data.screens}
|
||||
pending = list(used)
|
||||
while pending:
|
||||
for other in referenced_screens(data.screens[pending.pop()], data.screens):
|
||||
if other not in used:
|
||||
used.add(other)
|
||||
pending.append(other)
|
||||
return used
|
||||
|
||||
|
||||
def _is_engine_file(game_dir: Path, p: Path) -> bool:
|
||||
parts = [x.lower() for x in p.relative_to(game_dir).parts]
|
||||
return len(parts) > 1 and parts[0] in ENGINE_DIRS
|
||||
|
||||
@@ -11,7 +11,7 @@ Node = dict[str, Any]
|
||||
|
||||
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
|
||||
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
|
||||
"align", "pos", "anchor", "xysize", "size"}
|
||||
"align", "pos", "anchor", "xysize", "size", "blur"}
|
||||
|
||||
|
||||
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Ren'Py script (.rpy) → PGVN IR."""
|
||||
|
||||
import ast
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
@@ -27,6 +28,7 @@ from app.converter.rpy_helpers import (
|
||||
_static_props,
|
||||
)
|
||||
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
||||
from app.converter.screens import ScreenParser, referenced_screens, tokens
|
||||
|
||||
Node = dict[str, Any]
|
||||
|
||||
@@ -59,6 +61,8 @@ TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pix
|
||||
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"}
|
||||
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
|
||||
|
||||
@@ -74,6 +78,11 @@ class ScriptData:
|
||||
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)
|
||||
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)
|
||||
@@ -88,6 +97,17 @@ class ScriptData:
|
||||
warning_examples: dict[str, str] = field(default_factory=dict)
|
||||
stats: Counter = field(default_factory=Counter)
|
||||
|
||||
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:
|
||||
self.warnings[kind] += 1
|
||||
if blocking:
|
||||
@@ -680,7 +700,7 @@ class RpyParser:
|
||||
# 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))
|
||||
self.d.warn("Изображение задано выражением", self.where(child), blocking=False)
|
||||
frames.append({"img": {"expr": e}})
|
||||
continue
|
||||
if not frames or not frames[-1].get("d"):
|
||||
@@ -814,6 +834,10 @@ class RpyParser:
|
||||
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
|
||||
@@ -823,6 +847,16 @@ class RpyParser:
|
||||
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)
|
||||
@@ -932,12 +966,17 @@ class RpyParser:
|
||||
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:
|
||||
if fname in self.d.transforms or fname in self.d.atl:
|
||||
self.use_transform(fname)
|
||||
spec = {"props": self.d.transforms[fname], "name": fname}
|
||||
spec = {"props": self.d.transforms.get(fname, {}), "name": fname}
|
||||
if fname in self.d.atl:
|
||||
spec["atl"] = fname
|
||||
spec["args"], spec["kw"] = e["a"], e["kw"]
|
||||
return spec
|
||||
if "." not in fname and fname[:1].islower() and fname not in KNOWN_FACTORIES:
|
||||
# parameterised transform defined later (or in another file); the player resolves it by name
|
||||
self.use_transform(fname)
|
||||
return {"name": fname, "atl": fname, "args": e["a"], "kw": e["kw"]}
|
||||
if e and e["k"] == "name":
|
||||
self.use_transform(e["id"])
|
||||
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
|
||||
@@ -962,6 +1001,61 @@ class RpyParser:
|
||||
], "repeat": None}
|
||||
return {"props": {"xpos": start[0], "ypos": start[1]}, "atl": block}
|
||||
|
||||
def screen_parser(self, collect=None) -> ScreenParser:
|
||||
def warn(message: str, line: Line) -> None:
|
||||
if collect is None:
|
||||
self.d.warn(message, self.where(line))
|
||||
else:
|
||||
collect.append((message, self.where(line)))
|
||||
|
||||
return ScreenParser(warn, lambda source, line: self.transform_ref(source, line),
|
||||
lambda lines: self.atl_spec(lines, collect.append if collect is not None else None, set()))
|
||||
|
||||
def st_screen(self, line: Line) -> list[Node]:
|
||||
issues: list[tuple[str, str]] = []
|
||||
parsed = self.screen_parser(issues).screen(line)
|
||||
if parsed:
|
||||
name, definition = parsed
|
||||
self.d.screens[name] = definition # a later definition replaces an earlier one
|
||||
self.d.screen_pending[name] = issues
|
||||
if name in self.d.screen_used:
|
||||
self.use_screen(name)
|
||||
return []
|
||||
|
||||
def use_screen(self, name: str) -> None:
|
||||
self.d.use_screen(name)
|
||||
|
||||
def st_style(self, line: Line) -> list[Node]:
|
||||
match = re.fullmatch(r"style\s+([A-Za-z_]\w*)(?:\s+is\s+([A-Za-z_]\w*))?\s*:?", line.text.strip())
|
||||
if not match:
|
||||
return [] # `style x take y`, `style x clear`: nothing a browser style needs
|
||||
name, parent = match.groups()
|
||||
parser = self.screen_parser([])
|
||||
props: dict[str, Any] = {}
|
||||
for child in line.block:
|
||||
props.update(parser.properties(tokens(child.text), child, lenient=True))
|
||||
entry = self.d.styles.setdefault(name, {"is": parent, "props": {}})
|
||||
entry["is"] = parent or entry["is"]
|
||||
entry["props"].update(props)
|
||||
return []
|
||||
|
||||
def screen_call(self, c: Cursor, line: Line) -> Node | None:
|
||||
"""``name(args)`` after `call/show screen`; trailing `nopredict` and `with` are ignored."""
|
||||
source = re.split(r"\s+(?:nopredict|with)\b", c.rest(), maxsplit=1)[0].strip()
|
||||
call = self.expr(source, line) if source else None
|
||||
if call is None:
|
||||
self.d.warn("Не разобран вызов screen", self.where(line))
|
||||
return None
|
||||
if call["k"] == "name":
|
||||
name, args, kw = call["id"], [], {}
|
||||
elif call["k"] == "call" and call["f"]["k"] == "name":
|
||||
name, args, kw = call["f"]["id"], call["a"], call["kw"]
|
||||
else:
|
||||
self.d.warn("Не разобран вызов screen", self.where(line))
|
||||
return None
|
||||
self.use_screen(name)
|
||||
return {"name": name, "args": args, "kw": kw}
|
||||
|
||||
def st_scene(self, line: Line) -> list[Node]:
|
||||
c = self.cursor(line)
|
||||
c.keyword("scene")
|
||||
@@ -978,14 +1072,14 @@ class RpyParser:
|
||||
c = self.cursor(line)
|
||||
c.keyword("show")
|
||||
if c.keyword("screen"):
|
||||
self.d.warn("Не поддерживается: screen", self.where(line))
|
||||
return []
|
||||
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,
|
||||
node = {"op": "show", "img": {"textExpr": self.expr(c.until(_SHOW_KEYWORDS, ":"), line,
|
||||
const(""))}}
|
||||
self.show_clauses(c, line, node)
|
||||
return [node]
|
||||
@@ -998,7 +1092,11 @@ class RpyParser:
|
||||
c = self.cursor(line)
|
||||
c.keyword("hide")
|
||||
if c.keyword("screen"):
|
||||
return []
|
||||
name = c.word()
|
||||
if not name:
|
||||
return []
|
||||
self.d.screen_used.add(name)
|
||||
return [{"op": "hide_screen", "name": name}]
|
||||
spec = self.image_spec(c, line)
|
||||
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
|
||||
self.show_clauses(c, line, node)
|
||||
@@ -1113,8 +1211,8 @@ class RpyParser:
|
||||
c = self.cursor(line)
|
||||
c.keyword("call")
|
||||
if c.keyword("screen"):
|
||||
self.d.warn("Не поддерживается: call screen", self.where(line))
|
||||
return []
|
||||
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(""))}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
"""Ren'Py screen language → PGVN screen IR.
|
||||
|
||||
Only the part games use for their own interface is translated (HUDs, hotspot menus,
|
||||
phones, inventories). Whatever is not understood is reported through ``warn`` so a game
|
||||
never imports with a half-working screen.
|
||||
|
||||
A screen is ``{"params": [...], "props": {...}, "defaults": [[name, expr]], "body": [node]}``.
|
||||
Nodes:
|
||||
|
||||
* widget ``{"w": kind, "args": [expr], "props": {name: expr}, "body": [node]}``
|
||||
* ``{"c": "if", "branches": [{"c": expr | None, "body": [...]}]}``
|
||||
* ``{"c": "for", "t": target, "it": expr, "body": [...]}``
|
||||
* ``{"c": "use", "name": str, "args": [expr], "kw": {}, "body": [...]}`` (body = transcluded children)
|
||||
* ``{"c": "transclude"}``, ``{"c": "python", "code": [stmt]}``
|
||||
* ``{"c": "key", "key": expr, "action": expr}``, ``{"c": "timer", ...}``, ``{"c": "on", "event": str, ...}``
|
||||
"""
|
||||
import ast
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.converter import pyexpr
|
||||
from app.converter.atl import WARPER
|
||||
from app.converter.pyexpr import Unsupported
|
||||
from app.converter.rpy_lexer import Line
|
||||
|
||||
Node = dict[str, Any]
|
||||
|
||||
# Calls whose first argument names a screen to open
|
||||
OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"}
|
||||
|
||||
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button"}
|
||||
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar"}
|
||||
POSITIONAL = {"text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2}
|
||||
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
|
||||
|
||||
LAYOUT = set("""xpos ypos pos xanchor yanchor anchor xalign yalign align xcenter ycenter xoffset yoffset
|
||||
xsize ysize xysize xminimum yminimum xmaximum ymaximum minimum maximum xfill yfill area spacing xspacing
|
||||
yspacing padding xpadding ypadding left_padding right_padding top_padding bottom_padding margin xmargin
|
||||
ymargin left_margin right_margin top_margin bottom_margin width height""".split())
|
||||
LOOK = set("""background foreground idle hover insensitive selected selected_idle selected_hover
|
||||
selected_insensitive auto ground left_bar right_bar top_bar bottom_bar thumb alpha zoom rotate xzoom yzoom
|
||||
clipping fit_first xfit yfit box_reverse box_wrap order_reverse transpose rows cols crop size_group
|
||||
xtile ytile""".split())
|
||||
TEXT = set("""color size font bold italic underline strikethrough outlines text_align textalign line_spacing
|
||||
kerning layout first_indent min_width antialias xalign yalign justify hyperlink_functions slow_cps""".split())
|
||||
BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_mask mouse hover_sound
|
||||
activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range
|
||||
adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys
|
||||
text_style properties substitute prefix suffix repeat""".split())
|
||||
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
|
||||
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
|
||||
"right_gutter", "top_gutter", "bottom_gutter")
|
||||
IGNORED = {"xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "draggable",
|
||||
"child_size", "focus", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
|
||||
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
|
||||
"selected_insensitive")
|
||||
ALL = LAYOUT | LOOK | TEXT | BEHAVIOUR
|
||||
SCREEN_PROPS = {"zorder", "modal", "tag", "variant", "style_prefix", "layer", "sensitive", "style"}
|
||||
DESKTOP_VARIANTS = {"pc", "large", "web", "medium", "small"}
|
||||
OPERATORS = {"+", "-", "*", "/", "//", "%", "**", "==", "!=", "<", ">", "<=", ">=", "and", "or", "in", "is",
|
||||
"if", "else", "not"}
|
||||
GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\)|\[.*\])$", re.S)
|
||||
|
||||
|
||||
def known_property(name: str) -> bool:
|
||||
if name in ALL or name in IGNORED or name.startswith(IGNORED_PREFIXES):
|
||||
return True
|
||||
if name.startswith("text_") and name[5:] in TEXT | LAYOUT:
|
||||
return True
|
||||
for prefix in STATE_PREFIXES:
|
||||
if name.startswith(prefix + "_") and known_property(name[len(prefix) + 1:]):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ignorable(name: str) -> bool:
|
||||
return name in IGNORED or name.startswith(IGNORED_PREFIXES)
|
||||
|
||||
|
||||
def tokens(text: str) -> list[str]:
|
||||
"""Split on whitespace outside brackets and quotes (Ren'Py "simple expressions")."""
|
||||
out, cur, depth, quote, escaped = [], [], 0, "", False
|
||||
for ch in text:
|
||||
if quote:
|
||||
cur.append(ch)
|
||||
if escaped:
|
||||
escaped = False
|
||||
elif ch == "\\":
|
||||
escaped = True
|
||||
elif ch == quote:
|
||||
quote = ""
|
||||
elif ch in "\"'":
|
||||
quote = ch
|
||||
cur.append(ch)
|
||||
elif ch in "([{":
|
||||
depth += 1
|
||||
cur.append(ch)
|
||||
elif ch in ")]}":
|
||||
depth -= 1
|
||||
cur.append(ch)
|
||||
elif ch.isspace() and depth == 0:
|
||||
if cur:
|
||||
out.append("".join(cur))
|
||||
cur = []
|
||||
else:
|
||||
cur.append(ch)
|
||||
if cur:
|
||||
out.append("".join(cur))
|
||||
return out
|
||||
|
||||
|
||||
def read_value(words: list[str], i: int) -> tuple[str, int]:
|
||||
"""One property value starting at words[i]. Decompiled sources keep operators unparenthesised
|
||||
(``text "Day " + str(day)``), so a value continues while the next word is an operator."""
|
||||
parts = [words[i]]
|
||||
i += 1
|
||||
if parts[0] in ("not", "-") and i < len(words):
|
||||
parts.append(words[i])
|
||||
i += 1
|
||||
while i < len(words):
|
||||
if words[i].startswith("(") and re.search(r"[\w\])]$", parts[-1]): # `ToggleVariable ("x")`
|
||||
parts[-1] += words[i]
|
||||
i += 1
|
||||
elif parts[-1].endswith(",") or parts[-1] in OPERATORS: # `A(), B()` / `a +` continue with the next word
|
||||
parts.append(words[i])
|
||||
i += 1
|
||||
elif i + 1 < len(words) and words[i] in OPERATORS:
|
||||
parts.extend([words[i], words[i + 1]])
|
||||
i += 2
|
||||
else:
|
||||
break
|
||||
return " ".join(parts), i
|
||||
|
||||
|
||||
class ScreenParser:
|
||||
def __init__(self, warn: Callable[[str, Line], None], transform_ref: Callable[[str, Line], dict],
|
||||
atl: Callable[[list[Line]], tuple[dict, dict | None]] | None = None):
|
||||
self.warn = warn
|
||||
self.transform_ref = transform_ref
|
||||
self.atl = atl
|
||||
self.defaults: list[list[Any]] = []
|
||||
|
||||
def expr(self, source: str, line: Line) -> dict | None:
|
||||
try:
|
||||
return pyexpr.parse_expr(source)
|
||||
except Unsupported as exc:
|
||||
self.warn(f"Выражение в screen: {exc}", line)
|
||||
return None
|
||||
|
||||
# --- definition -----------------------------------------------------------------
|
||||
|
||||
def screen(self, line: Line) -> tuple[str, dict] | None:
|
||||
header = line.text.strip().rstrip(":").strip()
|
||||
match = re.fullmatch(r"screen\s+([A-Za-z_]\w*)\s*(\((.*)\))?", header, re.S)
|
||||
if not match:
|
||||
self.warn("Не разобран заголовок screen", line)
|
||||
return None
|
||||
name = match.group(1)
|
||||
try:
|
||||
params = pyexpr.params(ast.parse(f"def f({match.group(3) or ''}): pass").body[0].args)
|
||||
except (SyntaxError, Unsupported):
|
||||
self.warn(f"Не разобраны параметры screen {name}", line)
|
||||
return None
|
||||
self.defaults = []
|
||||
props, body = self.body(line.block, top=True)
|
||||
props.pop("__atl__", None)
|
||||
props.pop("__if__", None)
|
||||
if "variant" in props:
|
||||
variants = props["variant"]
|
||||
text = variants["v"] if variants.get("k") == "const" else ""
|
||||
if not any(v in DESKTOP_VARIANTS for v in re.findall(r"[a-z]+", str(text))):
|
||||
return None # touch/phone layouts never apply to the desktop browser player
|
||||
props.pop("variant")
|
||||
return name, {"params": params, "props": props, "defaults": self.defaults, "body": body}
|
||||
|
||||
# --- statements -----------------------------------------------------------------
|
||||
|
||||
def is_atl(self, words: list[str]) -> bool:
|
||||
first = words[0]
|
||||
if first == "on":
|
||||
return len(words) > 1 and not words[1].startswith(("'", '"'))
|
||||
return first in ATL_WORDS or bool(WARPER.match(first))
|
||||
|
||||
def body(self, lines: list[Line], top: bool = False) -> tuple[dict[str, Any], list[Node]]:
|
||||
"""Returns (properties declared in the block, child nodes)."""
|
||||
props: dict[str, Any] = {}
|
||||
nodes: list[Node] = []
|
||||
atl_lines: list[Line] = []
|
||||
conditional: list[list[dict]] = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
words = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip())
|
||||
i += 1
|
||||
if not words:
|
||||
continue
|
||||
first = words[0]
|
||||
if first in ("if", "showif"):
|
||||
branches = [self.branch(first, words, line)]
|
||||
while i < len(lines):
|
||||
nxt = tokens(lines[i].text.rstrip(":").strip())
|
||||
if not nxt or nxt[0] not in ("elif", "else"):
|
||||
break
|
||||
branches.append(self.branch(nxt[0], nxt, lines[i]))
|
||||
i += 1
|
||||
branches = [b for b in branches if b]
|
||||
if not top and branches and not any(b["body"] for b in branches) and any(b["props"] for b in branches):
|
||||
# `if` inside a widget that only sets properties (idle "a" / idle "b")
|
||||
conditional.append([{"c": b["c"], "props": b["props"]} for b in branches])
|
||||
else:
|
||||
nodes.append({"c": "if", "branches": [{"c": b["c"], "body": b["body"]} for b in branches]})
|
||||
elif first in ("elif", "else"):
|
||||
self.warn(f"Не поддерживается в screen: {first} без if", line)
|
||||
elif first == "for":
|
||||
node = self.for_loop(line)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
elif first == "use":
|
||||
node = self.use(line, words)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
elif first == "transclude":
|
||||
nodes.append({"c": "transclude"})
|
||||
elif first == "has":
|
||||
# `has vbox`: the rest of this block goes inside a new container
|
||||
container = self.widget(line, words[1:])
|
||||
if container:
|
||||
_, rest = self.body(lines[i:])
|
||||
container["body"].extend(rest)
|
||||
nodes.append(container)
|
||||
i = len(lines)
|
||||
elif first == "python" and line.text.strip() == "python:" or first == "$":
|
||||
nodes.extend(self.python(line, first))
|
||||
elif first == "default":
|
||||
self.default(line)
|
||||
elif first == "key":
|
||||
node = self.key(line, words)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
elif first == "timer":
|
||||
node = self.timer(line, words)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
elif first == "on" and not self.is_atl(words):
|
||||
node = self.on(line, words)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
elif self.is_atl(words) and not top:
|
||||
atl_lines.append(line)
|
||||
elif first in CONTAINERS or first in LEAVES:
|
||||
node = self.widget(line, words)
|
||||
if node:
|
||||
nodes.append(node)
|
||||
elif (top and first in SCREEN_PROPS) or known_property(GLUED.sub(r"\1", first)):
|
||||
props.update(self.properties(words, line))
|
||||
else:
|
||||
self.warn(f"Не поддерживается в screen: {first}", line)
|
||||
if atl_lines:
|
||||
props["__atl__"] = atl_lines
|
||||
if conditional:
|
||||
props["__if__"] = conditional
|
||||
return props, nodes
|
||||
|
||||
def branch(self, kind: str, words: list[str], line: Line) -> dict | None:
|
||||
cond = None
|
||||
if kind != "else":
|
||||
cond = self.expr(" ".join(words[1:]), line)
|
||||
if cond is None:
|
||||
return None
|
||||
props, body = self.body(line.block)
|
||||
props.pop("__atl__", None)
|
||||
return {"c": cond, "body": body, "props": props}
|
||||
|
||||
def for_loop(self, line: Line) -> Node | None:
|
||||
try:
|
||||
stmt = pyexpr.parse_stmts(line.text.strip().rstrip(":") + ":\n pass")[0]
|
||||
except Unsupported as exc:
|
||||
self.warn(f"Цикл в screen: {exc}", line)
|
||||
return None
|
||||
_, body = self.body(line.block)
|
||||
return {"c": "for", "t": stmt["t"], "it": stmt["it"], "body": body}
|
||||
|
||||
def use(self, line: Line, words: list[str]) -> Node | None:
|
||||
call = self.expr(" ".join(words[1:]), line)
|
||||
if call is None:
|
||||
return None
|
||||
if call["k"] == "name":
|
||||
node = {"c": "use", "name": call["id"], "args": [], "kw": {}}
|
||||
elif call["k"] == "call" and call["f"]["k"] == "name":
|
||||
node = {"c": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"]}
|
||||
else:
|
||||
self.warn("Не разобран use в screen", line)
|
||||
return None
|
||||
if line.block: # children are placed where the used screen says `transclude`
|
||||
_, node["body"] = self.body(line.block)
|
||||
return node
|
||||
|
||||
def python(self, line: Line, first: str) -> list[Node]:
|
||||
source = line.text.strip()[1:].strip() if first == "$" else line.block_source()
|
||||
try:
|
||||
return [{"c": "python", "code": pyexpr.parse_stmts(source)}]
|
||||
except Unsupported as exc:
|
||||
self.warn(f"Python в screen: {exc}", line)
|
||||
return []
|
||||
|
||||
def default(self, line: Line) -> None:
|
||||
match = re.fullmatch(r"default\s+([A-Za-z_]\w*)\s*=\s*(.+)", line.text.strip(), re.S)
|
||||
value = self.expr(match.group(2), line) if match else None
|
||||
if not match or value is None:
|
||||
self.warn("Не разобран default в screen", line)
|
||||
return
|
||||
self.defaults.append([match.group(1), value])
|
||||
|
||||
def block_properties(self, words: list[str], line: Line) -> dict[str, Any]:
|
||||
props = self.properties(words, line)
|
||||
for child in line.block:
|
||||
props.update(self.properties(tokens(child.text), child))
|
||||
return props
|
||||
|
||||
def key(self, line: Line, words: list[str]) -> Node | None:
|
||||
shortcut = self.expr(words[1], line) if len(words) > 1 else None
|
||||
props = self.block_properties(words[2:], line)
|
||||
if shortcut is None or "action" not in props:
|
||||
self.warn("Не разобран key в screen", line)
|
||||
return None
|
||||
return {"c": "key", "key": shortcut, "action": props["action"]}
|
||||
|
||||
def timer(self, line: Line, words: list[str]) -> Node | None:
|
||||
delay = self.expr(words[1], line) if len(words) > 1 else None
|
||||
props = self.block_properties(words[2:], line)
|
||||
if delay is None or "action" not in props:
|
||||
self.warn("Не разобран timer в screen", line)
|
||||
return None
|
||||
return {"c": "timer", "delay": delay, "action": props["action"], "repeat": props.get("repeat")}
|
||||
|
||||
def on(self, line: Line, words: list[str]) -> Node | None:
|
||||
event = self.expr(words[1], line) if len(words) > 1 else None
|
||||
props = self.block_properties(words[2:], line)
|
||||
if event is None or event.get("k") != "const" or "action" not in props:
|
||||
self.warn("Не разобран on в screen", line)
|
||||
return None
|
||||
if event["v"] not in ("show", "hide", "replace", "replaced"):
|
||||
self.warn(f"Не поддерживается в screen: событие {event['v']}", line)
|
||||
return None
|
||||
return {"c": "on", "event": event["v"], "action": props["action"]}
|
||||
|
||||
# --- widgets --------------------------------------------------------------------
|
||||
|
||||
def widget(self, line: Line, words: list[str]) -> Node | None:
|
||||
kind = words[0]
|
||||
if kind == "image":
|
||||
kind = "add"
|
||||
if kind not in CONTAINERS and kind not in LEAVES:
|
||||
self.warn(f"Не поддерживается в screen: {kind}", line)
|
||||
return None
|
||||
args = []
|
||||
i = 1
|
||||
for _ in range(POSITIONAL.get(kind, 0)):
|
||||
if i >= len(words):
|
||||
break
|
||||
source, i = read_value(words, i)
|
||||
value = self.expr(source, line)
|
||||
if value is None:
|
||||
return None
|
||||
args.append(value)
|
||||
if len(args) < POSITIONAL.get(kind, 0) and kind not in ("text", "label"):
|
||||
self.warn(f"У {kind} в screen не хватает аргументов", line)
|
||||
return None
|
||||
props = self.properties(words[i:], line)
|
||||
body: list[Node] = []
|
||||
if line.block:
|
||||
more, body = self.body(line.block)
|
||||
props.update(more)
|
||||
atl_lines = props.pop("__atl__", None)
|
||||
conditional = props.pop("__if__", None)
|
||||
if atl_lines:
|
||||
self.inline_atl(props, atl_lines, line)
|
||||
if body and kind in LEAVES:
|
||||
self.warn(f"{kind} в screen не может содержать вложенные элементы", line)
|
||||
return None
|
||||
if kind in ("bar", "vbar"):
|
||||
props["vertical"] = {"k": "const", "v": kind == "vbar"}
|
||||
node: Node = {"w": kind, "args": args, "props": props, "body": body}
|
||||
if conditional:
|
||||
node["propIf"] = conditional
|
||||
return node
|
||||
|
||||
def inline_atl(self, props: dict[str, Any], lines: list[Line], line: Line) -> None:
|
||||
if self.atl is None:
|
||||
self.warn("Не поддерживается в screen: ATL в виджете", line)
|
||||
return
|
||||
static, animation = self.atl(lines)
|
||||
spec: dict[str, Any] = {"props": static}
|
||||
if animation:
|
||||
spec["atl"] = animation
|
||||
props["at"] = [*props.get("at", []), spec]
|
||||
|
||||
def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]:
|
||||
props: dict[str, Any] = {}
|
||||
i = 0
|
||||
while i < len(words):
|
||||
key, glued = words[i], None
|
||||
match = GLUED.match(key)
|
||||
if match and (known_property(match[1]) or match[1] in SCREEN_PROPS):
|
||||
key, glued = match[1], match[2]
|
||||
if known_property(key) or key in SCREEN_PROPS:
|
||||
pass
|
||||
elif key in ("with", "nopredict"):
|
||||
i += 1
|
||||
continue
|
||||
elif lenient: # styles carry many purely cosmetic properties we do not render
|
||||
i += 2
|
||||
continue
|
||||
else:
|
||||
self.warn(f"Не поддерживается в screen: свойство {key}", line)
|
||||
return props
|
||||
if glued is None and i + 1 >= len(words):
|
||||
self.warn(f"У свойства {key} нет значения", line)
|
||||
return props
|
||||
if key == "at" and glued is None:
|
||||
# `at a, b` / `at transform_name(args)`; transforms are not Python values
|
||||
items, i = [], i + 1
|
||||
while i < len(words):
|
||||
items.append(words[i].rstrip(","))
|
||||
more = words[i].endswith(",")
|
||||
i += 1
|
||||
if not more:
|
||||
break
|
||||
props["at"] = [self.transform_ref(item, line) for item in items if item]
|
||||
continue
|
||||
if glued is not None:
|
||||
source, i = glued, i + 1
|
||||
else:
|
||||
source, i = read_value(words, i + 1)
|
||||
if ignorable(key):
|
||||
continue
|
||||
value = self.expr(source, line)
|
||||
if value is not None:
|
||||
props[key] = value
|
||||
return props
|
||||
|
||||
|
||||
def referenced_screens(definition: Any, known: dict[str, Any]) -> set[str]:
|
||||
"""Screens a screen may open: `use` targets and string constants naming a screen."""
|
||||
found: set[str] = set()
|
||||
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
if value.get("c") == "use" and value.get("name") in known:
|
||||
found.add(value["name"])
|
||||
if value.get("k") == "call" and value.get("a"):
|
||||
function, first = value["f"], value["a"][0]
|
||||
name = function.get("id") if function.get("k") == "name" else (
|
||||
function.get("a") if function.get("k") == "attr" else "")
|
||||
if (name in OPENERS and first.get("k") == "const" and first.get("v") in known):
|
||||
found.add(first["v"])
|
||||
for child in value.values():
|
||||
visit(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
visit(child)
|
||||
|
||||
visit(definition)
|
||||
return found
|
||||
@@ -14,6 +14,7 @@ from app.translation import file_json, put_json
|
||||
OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split())
|
||||
OPS |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"}
|
||||
OPS.add("nani_command")
|
||||
OPS |= {"call_screen", "show_screen", "hide_screen"}
|
||||
MISSING = object()
|
||||
|
||||
|
||||
@@ -77,6 +78,13 @@ def validate(doc, manifest):
|
||||
for key in ("expr", "c", "d"):
|
||||
if isinstance(n.get(key), dict):
|
||||
expression(n[key])
|
||||
if op in ("call_screen", "show_screen"):
|
||||
if not isinstance(n.get("name"), str) or not isinstance(n.get("args"), list):
|
||||
raise ValueError("Некорректный вызов screen")
|
||||
for item in [*n["args"], *(n.get("kw") or {}).values()]:
|
||||
expression(item)
|
||||
if op == "hide_screen" and not isinstance(n.get("name"), str):
|
||||
raise ValueError("Некорректный вызов screen")
|
||||
if op in {"say", "nani_add_choice"} or (op == "nani_command" and "what" in n):
|
||||
if not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids:
|
||||
raise ValueError("Некорректная реплика или повторяющийся tid")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Compile the screens/styles of a .rpy file to JSON: python -m tests.make_screens SOURCE.rpy OUT.json"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.converter.compatibility import audit_ir, require_supported_scripts
|
||||
from app.converter.pipeline import _used_screens
|
||||
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||
|
||||
|
||||
def main(source: Path, out: Path) -> None:
|
||||
data = ScriptData()
|
||||
RpyParser(data, source.name).parse(source.read_text(encoding="utf-8"))
|
||||
audit_ir(data)
|
||||
require_supported_scripts(data)
|
||||
shipped = {name: data.screens[name] for name in sorted(_used_screens(data))}
|
||||
out.write_text(json.dumps({"screens": shipped, "styles": data.styles, "atl": data.atl,
|
||||
"transforms": data.transforms}, ensure_ascii=False, separators=(",", ":")),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(Path(sys.argv[1]), Path(sys.argv[2]))
|
||||
@@ -73,13 +73,15 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
|
||||
write(tmp_path / "game/script.rpy", source)
|
||||
assert inspect_directory(tmp_path)["compatible"]
|
||||
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused'))
|
||||
with pytest.raises(CompatibilityError, match="call screen"):
|
||||
assert inspect_directory(tmp_path)["compatible"] # a defined screen can be called
|
||||
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen missing_screen'))
|
||||
with pytest.raises(CompatibilityError, match="screen"):
|
||||
inspect_directory(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source,reason", [
|
||||
('init python:\n def helper():\n yield 1\nlabel start:\n "Hi"', "init python"),
|
||||
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"),
|
||||
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
|
||||
('label start:\n jump missing', "missing"),
|
||||
])
|
||||
def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import pytest
|
||||
|
||||
from app.converter.compatibility import CompatibilityError, audit_ir, require_supported_scripts
|
||||
from app.converter.rpy_lexer import parse_tree
|
||||
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||
|
||||
HUD = '''
|
||||
style hud_text:
|
||||
size 28
|
||||
color "#fff"
|
||||
unknown_cosmetic_thing 3
|
||||
|
||||
transform pulse:
|
||||
zoom 1.0
|
||||
linear 0.5 zoom 1.1
|
||||
repeat
|
||||
|
||||
screen hud(day=1):
|
||||
zorder 5
|
||||
tag hud
|
||||
default open = False
|
||||
frame:
|
||||
xalign 0.0 yalign 0.0
|
||||
background "#0008"
|
||||
vbox:
|
||||
spacing 4
|
||||
text "Day [day]" style "hud_text"
|
||||
if money > 10:
|
||||
text "Rich" color "#0f0"
|
||||
else:
|
||||
text "Poor"
|
||||
for item in inventory:
|
||||
textbutton item action [SetVariable("held", item), Hide("hud")] at pulse
|
||||
key "K_h" action ToggleScreenVariable("open")
|
||||
timer 1.0 action NullAction() repeat True
|
||||
use footer("bye")
|
||||
|
||||
screen footer(label):
|
||||
imagebutton auto "btn_%s.png" action Return(1) hover_sound "tick.ogg"
|
||||
|
||||
label start:
|
||||
$ money = 20
|
||||
$ inventory = ["key"]
|
||||
show screen hud(2)
|
||||
call screen footer("x")
|
||||
$ r = _return
|
||||
hide screen hud
|
||||
return
|
||||
'''
|
||||
|
||||
|
||||
def parse(source):
|
||||
data = ScriptData()
|
||||
RpyParser(data, "script.rpy").parse(source)
|
||||
return data
|
||||
|
||||
|
||||
def test_screens_styles_and_calls_become_ir():
|
||||
data = parse(HUD)
|
||||
assert not data.blockers, data.blockers
|
||||
hud = data.screens["hud"]
|
||||
assert hud["params"][0]["n"] == "day"
|
||||
assert hud["props"]["zorder"] == {"k": "const", "v": 5} and hud["props"]["tag"]["id"] == "hud"
|
||||
assert hud["defaults"] == [["open", {"k": "const", "v": False}]]
|
||||
frame = hud["body"][0]
|
||||
assert frame["w"] == "frame" and frame["props"]["background"]["v"] == "#0008"
|
||||
box = frame["body"][0]
|
||||
assert box["w"] == "vbox" and [n.get("w") or n["c"] for n in box["body"]] == ["text", "if", "for"]
|
||||
button = box["body"][2]["body"][0]
|
||||
assert button["w"] == "textbutton" and button["props"]["action"]["k"] == "list"
|
||||
assert button["props"]["at"] == [{"props": {"zoom": 1.0}, "name": "pulse", "atl": "pulse"}]
|
||||
assert [n.get("c") for n in hud["body"][1:]] == ["key", "timer", "use"]
|
||||
assert data.styles["hud_text"]["props"]["size"]["v"] == 28
|
||||
assert "unknown_cosmetic_thing" not in data.styles["hud_text"]["props"]
|
||||
ops = [n["op"] for label in data.modules["script.rpy"] for n in label["body"] if n["op"].endswith("screen")]
|
||||
assert ops == ["show_screen", "call_screen", "hide_screen"]
|
||||
call = next(n for label in data.modules["script.rpy"] for n in label["body"] if n["op"] == "call_screen")
|
||||
assert call["name"] == "footer" and call["args"][0] == {"k": "const", "v": "x"}
|
||||
|
||||
|
||||
def test_shown_screens_are_audited_but_unshown_ones_are_not():
|
||||
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n "Hi"\n')
|
||||
audit_ir(data)
|
||||
require_supported_scripts(data) # never shown: harmless
|
||||
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n call screen odd\n')
|
||||
audit_ir(data)
|
||||
with pytest.raises(CompatibilityError, match="screen"):
|
||||
require_supported_scripts(data)
|
||||
|
||||
|
||||
def test_unknown_actions_and_screens_block_the_import():
|
||||
data = parse('screen s():\n textbutton "x" action Preference("fullscreen")\nlabel start:\n call screen s\n')
|
||||
audit_ir(data)
|
||||
with pytest.raises(CompatibilityError, match="Preference"):
|
||||
require_supported_scripts(data)
|
||||
data = parse('label start:\n call screen nowhere\n')
|
||||
audit_ir(data)
|
||||
with pytest.raises(CompatibilityError, match="screen"):
|
||||
require_supported_scripts(data)
|
||||
|
||||
|
||||
def test_screens_opened_from_python_and_by_actions_are_shipped():
|
||||
from app.converter.pipeline import _used_screens
|
||||
|
||||
data = parse(
|
||||
'screen a():\n textbutton "x" action Show("b")\nscreen b():\n text "B"\nscreen c():\n text "C"\n'
|
||||
'screen d():\n text "D"\nlabel start:\n $ renpy.show_screen("d")\n call screen a\n')
|
||||
audit_ir(data)
|
||||
require_supported_scripts(data)
|
||||
assert _used_screens(data) == {"a", "b", "d"}
|
||||
|
||||
|
||||
def test_touch_variants_are_dropped_and_later_definitions_win():
|
||||
data = parse(
|
||||
'screen s():\n text "one"\nscreen s():\n variant "touch"\n text "touch"\n'
|
||||
'screen t():\n text "one"\nscreen t():\n text "two"\nlabel start:\n "x"\n')
|
||||
assert data.screens["s"]["body"][0]["args"][0]["v"] == "one"
|
||||
assert data.screens["t"]["body"][0]["args"][0]["v"] == "two"
|
||||
|
||||
|
||||
def test_lexer_keeps_screen_blocks_nested():
|
||||
lines = parse_tree("screen s():\n vbox:\n text \"a\"\n")
|
||||
assert lines[0].block[0].block[0].text.strip().startswith("text")
|
||||
|
||||
|
||||
def test_decompiled_forms_operators_glued_properties_has_transclude_and_inline_atl():
|
||||
data = parse(
|
||||
'transform blink(delay, cycle=1.0):\n alpha 0.0\n pause delay\n linear cycle alpha 1.0\n'
|
||||
'screen card(title):\n frame:\n has vbox\n text title\n transclude\n'
|
||||
'screen deck(day):\n use card("T"):\n text "Day " + str(day) size 20\n'
|
||||
' text "x" pos(5, 6) xysize(10,20)\n'
|
||||
' add "bg.png":\n alpha 0.0\n linear 0.5 alpha 1.0\n'
|
||||
' add "dot.png" at blink(0.2, 0.3)\n'
|
||||
' on "show" action NullAction()\n'
|
||||
'label start:\n call screen deck(1)\n')
|
||||
audit_ir(data)
|
||||
require_supported_scripts(data)
|
||||
deck = data.screens["deck"]["body"]
|
||||
use = deck[0]
|
||||
assert use["c"] == "use" and use["body"][0]["args"][0]["k"] == "bin"
|
||||
assert use["body"][0]["props"]["size"]["v"] == 20
|
||||
card = data.screens["card"]["body"][0]
|
||||
assert card["w"] == "frame" and [n.get("w") or n["c"] for n in card["body"][0]["body"]] == ["text", "transclude"]
|
||||
assert deck[1]["props"]["pos"]["k"] == "tuple" and deck[1]["props"]["xysize"]["k"] == "tuple"
|
||||
assert deck[2]["props"]["alpha"] == {"k": "const", "v": 0.0}
|
||||
animated = deck[2]["props"]["at"][0]
|
||||
assert animated["atl"]["steps"][-1]["op"] == "move" and animated["atl"]["steps"][-1]["props"] == {"alpha": 1.0}
|
||||
called = deck[3]["props"]["at"][0]
|
||||
assert called["name"] == "blink" and called["atl"] == "blink" and len(called["args"]) == 2
|
||||
blink = data.atl["blink"]
|
||||
assert [p["n"] for p in blink["params"]] == ["delay", "cycle"]
|
||||
assert blink["steps"][1] == {"op": "pause", "t": {"e": {"k": "name", "id": "delay"}}}
|
||||
assert deck[4] == {"c": "on", "event": "show", "action": {"k": "call", "f": {"k": "name", "id": "NullAction"}, "a": [], "kw": {}}}
|
||||
|
||||
|
||||
def test_computed_atl_values_make_a_transform_animated():
|
||||
data = parse('transform shift(x):\n xoffset x\nlabel start:\n "Hi"\n')
|
||||
block = data.atl["shift"]
|
||||
assert block["params"][0]["n"] == "x"
|
||||
assert block["steps"][0]["props"]["xoffset"] == {"e": {"k": "name", "id": "x"}}
|
||||
assert data.transforms["shift"] == {}
|
||||
Reference in new issue
Block a user