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:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 13:17:11 +03:00
1 parent 47fc121e7d
commit 62ece25902
24 files changed
+2997 -60

No files matched your search

+107 -9
View File
@@ -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(""))}