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
@@ -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(""))}
|
||||
|
||||
Reference in new issue
Block a user