feat: generator interpreter for Python that waits for the player (Ren'Py)

Python containing renpy.input, narrator("..."), character calls, renpy.say,
renpy.pause, renpy.display_menu, renpy.with_statement or renpy.call_screen now
runs through loops, try, functions, methods and expressions.

Player
- python.ts: execG/evalG/callG run only the code that may wait (mayBlock);
  everything else stays on the synchronous fast path. A waiting call returns a
  BlockingCall; outside the generator interpreter it is an explicit error. The
  interpreter scope is set aside while the player decides
- engine: drives the generator and answers requests (dialogue, text input with
  length/allow/exclude, pause, choice menu, transition, call screen)
- renpy.show/hide/scene, transitions and transforms as Python values,
  Character(...) objects, Borders, set_tag_attributes

Converter
- blocking_names() lists game functions that may wait, transitively, and ships
  them as game.json "blocking"; character calls are allowed
- hide screen of an unknown screen is harmless; images defined by a parenthesised
  expression are supported

Hooked now passes the strict compatibility check (with Tomie and Twisted-Thoughts).
Tests: waiting.py fixture run through the generator interpreter; blocking-name analysis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:22:38 +03:00
1 parent ea03cebf6a
commit 3923ee72fb
11 files changed
+698 -27

No files matched your search

+20 -15
View File
@@ -717,26 +717,32 @@ class RpyParser:
return bool(m and m.group(1) in _DISPLAYABLE_CALLS)
def expression_picture(self, line: Line) -> dict[str, Any] | None:
"""``image x:`` whose first line is a call computing the picture (``get_face("base")``) followed by
transform lines: the picture is evaluated when the image is shown, the transforms are ATL."""
"""``image x:`` whose first line is an expression computing the picture (``get_face("base")`` or
``("a_" + str(n))``), optionally followed by transform lines: it is evaluated when the image is shown."""
if not line.block:
return None
first = line.block[0]
match = _CALL_LINE.match(first.text)
text = first.text.strip()
match = _CALL_LINE.match(text)
word = Cursor(first.text).peek_word() or ""
if (not match or self.displayable_line(first) or match.group(1) in self.d.transforms
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
if not (match or text.startswith("(")):
return None
if match and (self.displayable_line(first) or match.group(1) in self.d.transforms
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
return None
expr = self.expr(text, first)
if expr is None:
return None
picture: dict[str, Any] = {"expr": expr}
rest = line.block[1:]
expr = self.expr(first.text, first)
if expr is None or not rest:
return None
issues: list[tuple[str, str]] = []
props, animation = self.atl_spec(rest, issues.append, set())
if issues or not animation:
return None
if rest:
issues: list[tuple[str, str]] = []
props, animation = self.atl_spec(rest, issues.append, set())
if issues or not animation:
return None
picture["at"] = [{"props": props, "atl": animation}]
self.d.warn("Изображение задано выражением", self.where(first), blocking=False)
return {"expr": expr, "at": [{"props": props, "atl": animation}]}
return picture
def atl_image(self, line: Line) -> dict[str, Any] | None:
if (picture := self.expression_picture(line)) is not None:
@@ -1231,8 +1237,7 @@ class RpyParser:
name = c.word()
if not name:
return []
self.d.screen_used.add(name)
return [{"op": "hide_screen", "name": name}]
return [{"op": "hide_screen", "name": name}] # hiding a screen that does not exist is harmless
spec = self.image_spec(c, line)
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
self.show_clauses(c, line, node)