Files
pgames-manager/backend/app/converter/screens.py
T
mrleo1nidandClaude Sonnet 5.5 ea03cebf6a feat: ATL pictures/contains/function/time, colour matrices and more Python for Ren'Py
ATL
- Image blocks become timelines: picture steps with dissolve, parallel, contains,
  finite repeat, time, function f(trans, st, at), calls of other transforms with
  arguments, on hover/idle events for buttons. Animations stop when their element
  leaves the page
- Pictures defined by an expression (get_face_path("base")) are drawn at show time
- Purely graphical hints (shader, mesh, gl_*, u_*) are accepted and dropped

Colour
- matrixcolor and im.matrix.* as 5x5 matrices combined with `*`, applied through SVG
  filters; im.MatrixColor/Scale/Crop/Blur/Composite and Text/Image as values

Python
- match statement, re/copy/time/datetime/defaultdict/functools.partial, more
  str/dict methods, Character(...) created by code
- narrator("..."), Character calls, renpy.say and renpy.show/hide with expressions in
  Python blocks become script nodes
- Audit follows reachability: functions and classes nothing refers to (custom
  statement lexers, unused tools) no longer block an import

Docs: README and ARCHITECTURE now describe a multi-engine library (GameMaker and
QSP planned). Tests for the new runtime pieces.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 14:05:14 +03:00

443 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.exprtokens import read_value
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 matrixcolor blur""".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"}
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
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]
text = line.text.rstrip(":").strip() if line.block else line.text.strip()
words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text))
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(re.sub(r"^(elif|else)\(", r"\1 (", 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