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
|
||||
Reference in new issue
Block a user