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

+6 -4
View File
@@ -125,10 +125,12 @@ Python игры выполняется в песочнице плеера: св
(`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`, (`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`,
`block`, `on show`/`on hide`, вызов других transform. `block`, `on show`/`on hide`, вызов других transform.
Пока **не** поддерживается: `screen`-язык (кастомные экраны, карты, Экраны игры (`screen`, `call screen`, `show screen`) и их действия — см.
мини-игры), в ATL — `function`, `time`, `contains`, `matrixcolor` и шейдеры, [docs/SCREENS.md](docs/SCREENS.md).
Пока **не** поддерживается: в экранах — `input`, `imagemap`, `mousearea`, `drag`, `side`, в ATL —
`function`, `time`, `contains`, `matrixcolor` и шейдеры,
в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы, в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы,
`with`, системные модули (`os`, `socket`, …) и картинки, которые игра вычисляет `with`, системные модули (`os`, `socket`, …) и картинки, которые игра вычисляет
своими функциями. Такие места перечислены в отчёте конвертации у версии и своими функциями. Такие места перечислены в отчёте конвертации у версии и
останавливают импорт. Игры-«песочницы», где навигация построена на экранах, останавливают импорт. Игры-«песочницы» на `renpy.ui.*` и `im.*` пока не играбельны.
пока не играбельны.
+32 -13
View File
@@ -11,6 +11,9 @@ A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps:
* ``{"op": "on", "events": [...], "block": block}`` * ``{"op": "on", "events": [...], "block": block}``
* ``{"op": "use", "name": transform}`` * ``{"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 Everything else (``function``, ``contains``, ``time``, expressions, shaders, matrix
colours) is reported through ``warn`` so the game is not imported half-working. 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 collections.abc import Callable
from typing import Any from typing import Any
from app.converter import pyexpr
from app.converter.rpy_helpers import POSITION_PROPS from app.converter.rpy_helpers import POSITION_PROPS
from app.converter.rpy_lexer import Line from app.converter.rpy_lexer import Line
@@ -69,12 +73,24 @@ class AtlParser:
# --- helpers ------------------------------------------------------------------- # --- 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: try:
value = ast.literal_eval(raw) return ast.literal_eval(raw)
except (ValueError, SyntaxError): except (ValueError, SyntaxError):
pass
try:
return {"e": pyexpr.parse_expr(raw)}
except pyexpr.Unsupported:
self.warn(f"Не поддерживается ATL: выражение {raw}", line) self.warn(f"Не поддерживается ATL: выражение {raw}", line)
return None 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)): if isinstance(value, bool) or not isinstance(value, (int, float)):
self.warn(f"Не поддерживается ATL: значение {raw}", line) self.warn(f"Не поддерживается ATL: значение {raw}", line)
return None return None
@@ -94,15 +110,10 @@ class AtlParser:
if i + 1 >= len(parts): if i + 1 >= len(parts):
self.warn(f"Не поддерживается ATL: у {key} нет значения", line) self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
return out return out
raw = parts[i + 1] value = self.value(parts[i + 1], line)
try: if value is None:
value = ast.literal_eval(raw)
except (ValueError, SyntaxError):
self.warn(f"Не поддерживается ATL: выражение {raw}", line)
return out return out
if isinstance(value, tuple): out[key] = list(value) if isinstance(value, tuple) else value
value = list(value)
out[key] = value
i += 2 i += 2
return out return out
@@ -123,7 +134,7 @@ class AtlParser:
if first == "repeat": if first == "repeat":
repeat = True if len(parts) == 1 else self.number(parts[1], line) repeat = True if len(parts) == 1 else self.number(parts[1], line)
if repeat is not True and repeat is not None: 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": elif first == "pause":
seconds = self.number(parts[1], line) if len(parts) > 1 else None seconds = self.number(parts[1], line) if len(parts) > 1 else None
if seconds is not None: if seconds is not None:
@@ -182,7 +193,7 @@ class AtlParser:
words = tokens(current.text.rstrip(":").strip()) words = tokens(current.text.rstrip(":").strip())
if not words or words[0] != first: if not words or words[0] != first:
break break
weight = 1.0 weight: Any = 1.0
if first == "choice" and len(words) > 1: if first == "choice" and len(words) > 1:
weight = self.number(words[1], current) or 1.0 weight = self.number(words[1], current) or 1.0
group.append((weight, self.block(current.block))) group.append((weight, self.block(current.block)))
@@ -194,10 +205,18 @@ class AtlParser:
return j 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: def static_props(block: Block) -> dict[str, Any] | None:
"""Merged properties when the block only assigns, else None (it needs the timeline).""" """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"]): if block["repeat"] is not None or any(step["op"] != "set" for step in block["steps"]):
return None return None
if any(computed(v) for step in block["steps"] for v in step["props"].values()):
return None
merged: dict[str, Any] = {} merged: dict[str, Any] = {}
for step in block["steps"]: for step in block["steps"]:
merged.update(step["props"]) merged.update(step["props"])
@@ -210,5 +229,5 @@ def initial_props(block: Block) -> dict[str, Any]:
for step in block["steps"]: for step in block["steps"]:
if step["op"] != "set": if step["op"] != "set":
break break
merged.update(step["props"]) merged.update({k: v for k, v in step["props"].items() if not computed(v)})
return merged return merged
+53 -1
View File
@@ -105,6 +105,16 @@ CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
for method in ("set_volume", "set_pause", "set_pan")} for method in ("set_volume", "set_pause", "set_pan")}
CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition"} CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition"}
CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"} CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"}
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullAction Play Stop Notify
Quit MainMenu If GetTooltip StaticValue VariableValue AnimatedValue FieldValue SelectedIf
SensitiveIf Queue ToggleScreen Language ShowMenu OpenURL Crop LiveCrop Solid Frame Null Transform
DynamicDisplayable Color Dissolve Fade ImageDissolve MoveTransition CropMove PushMove Pixellate
ComposeTransition With""".split())
CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "renpy.seen_image",
"renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction",
"renpy.get_screen", "renpy.call_screen"}
METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split()) METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split())
@@ -139,6 +149,11 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
target(value.get("t")) target(value.get("t"))
elif kind == "lambda": elif kind == "lambda":
names.update(p["n"] for p in value["params"]) names.update(p["n"] for p in value["params"])
elif value.get("c") == "for":
target(value.get("t"))
elif "params" in value and "defaults" in value and "body" in value: # a screen
names.update(p["n"] for p in value["params"])
names.update(name for name, _ in value["defaults"])
elif kind == "comp": elif kind == "comp":
target(value.get("t")) target(value.get("t"))
for gen in value.get("gens", []): for gen in value.get("gens", []):
@@ -152,9 +167,42 @@ def _bound_names(root) -> tuple[set[str], set[str]]:
return names, methods return names, methods
SCREEN_OPENERS = {"renpy.show_screen", "renpy.call_screen", "renpy.hide_screen", "Show", "ShowTransient",
"Hide"}
def mark_screen_calls(data) -> None:
"""`renpy.show_screen("x")` in game code uses screen x, just like `show screen x`."""
def visit(value):
if isinstance(value, list):
for item in value:
visit(item)
elif isinstance(value, dict):
if value.get("k") == "call" and value.get("a"):
name = value["f"]["id"] if value["f"].get("k") == "name" else (
"renpy." + value["f"]["a"] if value["f"].get("k") == "attr"
and value["f"]["o"].get("k") == "name" and value["f"]["o"]["id"] == "renpy" else "")
first = value["a"][0]
if name in SCREEN_OPENERS and first.get("k") == "const" and isinstance(first["v"], str):
if first["v"] in data.screens and first["v"] not in data.screen_used:
data.use_screen(first["v"])
changed.append(first["v"])
for child in value.values():
visit(child)
changed = [True]
while changed:
changed.clear()
visit([data.modules, data.init_code, data.defaults,
[data.screens[n] for n in data.screen_used if n in data.screens]])
def audit_ir(data) -> None: def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None.""" """Reject calls which the browser would otherwise silently replace by None."""
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images]) mark_screen_calls(data)
used_screens = {n: data.screens[n] for n in data.screen_used if n in data.screens}
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images,
list(used_screens.values())])
user_names |= {name for name, _ in data.defaults} user_names |= {name for name, _ in data.defaults}
def dotted(value): def dotted(value):
@@ -189,5 +237,9 @@ def audit_ir(data) -> None:
for path, module in data.modules.items(): for path, module in data.modules.items():
visit(module, path) visit(module, path)
visit(data.defaults, "default") visit(data.defaults, "default")
for name, definition in used_screens.items():
visit(definition, f"screen {name}")
for name in sorted(data.screen_used - set(data.screens)):
data.warn("Не найден screen", name)
visit(data.init_code, "init") visit(data.init_code, "init")
visit(data.images, "image") visit(data.images, "image")
+16
View File
@@ -191,6 +191,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
"images": images, "images": images,
"transforms": data.transforms, "transforms": data.transforms,
"atl": data.atl, "atl": data.atl,
"screens": {name: data.screens[name] for name in sorted(_used_screens(data))},
"styles": data.styles,
"transitions": data.transitions, "transitions": data.transitions,
"audio": audio, "audio": audio,
"channels": data.channels, "channels": data.channels,
@@ -297,6 +299,20 @@ def _size(path: Path) -> int:
ENGINE_DIRS = ("cache", "saves", "tl", "renpy", "0x52-urm") ENGINE_DIRS = ("cache", "saves", "tl", "renpy", "0x52-urm")
def _used_screens(data: ScriptData) -> set[str]:
"""Screens the game shows (and the ones those open); the rest stay out of game.json."""
from app.converter.screens import referenced_screens
used = {name for name in data.screen_used if name in data.screens}
pending = list(used)
while pending:
for other in referenced_screens(data.screens[pending.pop()], data.screens):
if other not in used:
used.add(other)
pending.append(other)
return used
def _is_engine_file(game_dir: Path, p: Path) -> bool: def _is_engine_file(game_dir: Path, p: Path) -> bool:
parts = [x.lower() for x in p.relative_to(game_dir).parts] parts = [x.lower() for x in p.relative_to(game_dir).parts]
return len(parts) > 1 and parts[0] in ENGINE_DIRS return len(parts) > 1 and parts[0] in ENGINE_DIRS
+1 -1
View File
@@ -11,7 +11,7 @@ Node = dict[str, Any]
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter", POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset", "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size"} "align", "pos", "anchor", "xysize", "size", "blur"}
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} _SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
+107 -9
View File
@@ -1,6 +1,7 @@
"""Ren'Py script (.rpy) → PGVN IR.""" """Ren'Py script (.rpy) → PGVN IR."""
import ast import ast
import re
from collections import Counter from collections import Counter
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -27,6 +28,7 @@ from app.converter.rpy_helpers import (
_static_props, _static_props,
) )
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
from app.converter.screens import ScreenParser, referenced_screens, tokens
Node = dict[str, Any] Node = dict[str, Any]
@@ -59,6 +61,8 @@ TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pix
POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top", POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top",
"offscreenleft", "offscreenright", "default", "reset"} "offscreenleft", "offscreenright", "default", "reset"}
CHANNELS = {"music", "sound", "voice", "audio", "movie"} 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"} SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"} 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_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
atl_uses: dict[str, set[str]] = field(default_factory=dict) atl_uses: dict[str, set[str]] = field(default_factory=dict)
atl_referenced: set[str] = field(default_factory=set) 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) transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
audio: dict[str, str] = field(default_factory=dict) audio: dict[str, str] = field(default_factory=dict)
defaults: list[list[Any]] = field(default_factory=list) defaults: list[list[Any]] = field(default_factory=list)
@@ -88,6 +97,17 @@ class ScriptData:
warning_examples: dict[str, str] = field(default_factory=dict) warning_examples: dict[str, str] = field(default_factory=dict)
stats: Counter = field(default_factory=Counter) 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: def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
self.warnings[kind] += 1 self.warnings[kind] += 1
if blocking: if blocking:
@@ -680,7 +700,7 @@ class RpyParser:
# e.g. get_face_path("base"): an image computed by game code # e.g. get_face_path("base"): an image computed by game code
e = self.expr(child.text, child) e = self.expr(child.text, child)
if e is not None: if e is not None:
self.d.warn("Изображение задано выражением", self.where(child)) self.d.warn("Изображение задано выражением", self.where(child), blocking=False)
frames.append({"img": {"expr": e}}) frames.append({"img": {"expr": e}})
continue continue
if not frames or not frames[-1].get("d"): if not frames or not frames[-1].get("d"):
@@ -814,6 +834,10 @@ class RpyParser:
issues: list[tuple[str, str]] = [] issues: list[tuple[str, str]] = []
uses: set[str] = set() uses: set[str] = set()
props, animation = self.atl_spec(line.block, issues.append, uses) 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 self.d.transforms[name] = props
if animation: if animation:
self.d.atl[name] = animation self.d.atl[name] = animation
@@ -823,6 +847,16 @@ class RpyParser:
self.use_transform(name) self.use_transform(name)
return [] 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: def use_transform(self, name: str) -> None:
"""A transform is used by the game: its unsupported constructs now block the import.""" """A transform is used by the game: its unsupported constructs now block the import."""
self.d.atl_referenced.add(name) self.d.atl_referenced.add(name)
@@ -932,12 +966,17 @@ class RpyParser:
args = [_const(a) for a in e["a"]] args = [_const(a) for a in e["a"]]
return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5, return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5,
"dist": _const(e["kw"].get("dist")) or 20} "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) 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: if fname in self.d.atl:
spec["atl"] = fname spec["atl"] = fname
spec["args"], spec["kw"] = e["a"], e["kw"]
return spec 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": if e and e["k"] == "name":
self.use_transform(e["id"]) self.use_transform(e["id"])
if e and e["k"] == "name" and e["id"] not in self.d.transforms: if e and e["k"] == "name" and e["id"] not in self.d.transforms:
@@ -962,6 +1001,61 @@ class RpyParser:
], "repeat": None} ], "repeat": None}
return {"props": {"xpos": start[0], "ypos": start[1]}, "atl": block} 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]: def st_scene(self, line: Line) -> list[Node]:
c = self.cursor(line) c = self.cursor(line)
c.keyword("scene") c.keyword("scene")
@@ -978,14 +1072,14 @@ class RpyParser:
c = self.cursor(line) c = self.cursor(line)
c.keyword("show") c.keyword("show")
if c.keyword("screen"): if c.keyword("screen"):
self.d.warn("Не поддерживается: screen", self.where(line)) node = self.screen_call(c, line)
return [] return [{"op": "show_screen", **node}] if node else []
if c.keyword("text"): if c.keyword("text"):
text = c.string() text = c.string()
if text is not None: if text is not None:
node: Node = {"op": "show", "img": {"text": text}} node: Node = {"op": "show", "img": {"text": text}}
else: 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(""))}} const(""))}}
self.show_clauses(c, line, node) self.show_clauses(c, line, node)
return [node] return [node]
@@ -998,7 +1092,11 @@ class RpyParser:
c = self.cursor(line) c = self.cursor(line)
c.keyword("hide") c.keyword("hide")
if c.keyword("screen"): 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) spec = self.image_spec(c, line)
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]} node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
self.show_clauses(c, line, node) self.show_clauses(c, line, node)
@@ -1113,8 +1211,8 @@ class RpyParser:
c = self.cursor(line) c = self.cursor(line)
c.keyword("call") c.keyword("call")
if c.keyword("screen"): if c.keyword("screen"):
self.d.warn("Не поддерживается: call screen", self.where(line)) node = self.screen_call(c, line)
return [] return [{"op": "call_screen", **node}] if node else []
if c.keyword("expression"): if c.keyword("expression"):
node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line, node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line,
const(""))} const(""))}
+465
View File
@@ -0,0 +1,465 @@
"""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.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""".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"}
OPERATORS = {"+", "-", "*", "/", "//", "%", "**", "==", "!=", "<", ">", "<=", ">=", "and", "or", "in", "is",
"if", "else", "not"}
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
def read_value(words: list[str], i: int) -> tuple[str, int]:
"""One property value starting at words[i]. Decompiled sources keep operators unparenthesised
(``text "Day " + str(day)``), so a value continues while the next word is an operator."""
parts = [words[i]]
i += 1
if parts[0] in ("not", "-") and i < len(words):
parts.append(words[i])
i += 1
while i < len(words):
if words[i].startswith("(") and re.search(r"[\w\])]$", parts[-1]): # `ToggleVariable ("x")`
parts[-1] += words[i]
i += 1
elif parts[-1].endswith(",") or parts[-1] in OPERATORS: # `A(), B()` / `a +` continue with the next word
parts.append(words[i])
i += 1
elif i + 1 < len(words) and words[i] in OPERATORS:
parts.extend([words[i], words[i + 1]])
i += 2
else:
break
return " ".join(parts), i
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]
words = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip())
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(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
+8
View File
@@ -14,6 +14,7 @@ from app.translation import file_json, put_json
OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split()) OPS = set("label camera say scene show hide with play stop queue voice pause menu if while jump call return exec window noop nvl".split())
OPS |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"} OPS |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"}
OPS.add("nani_command") OPS.add("nani_command")
OPS |= {"call_screen", "show_screen", "hide_screen"}
MISSING = object() MISSING = object()
@@ -77,6 +78,13 @@ def validate(doc, manifest):
for key in ("expr", "c", "d"): for key in ("expr", "c", "d"):
if isinstance(n.get(key), dict): if isinstance(n.get(key), dict):
expression(n[key]) expression(n[key])
if op in ("call_screen", "show_screen"):
if not isinstance(n.get("name"), str) or not isinstance(n.get("args"), list):
raise ValueError("Некорректный вызов screen")
for item in [*n["args"], *(n.get("kw") or {}).values()]:
expression(item)
if op == "hide_screen" and not isinstance(n.get("name"), str):
raise ValueError("Некорректный вызов screen")
if op in {"say", "nani_add_choice"} or (op == "nani_command" and "what" in n): if op in {"say", "nani_add_choice"} or (op == "nani_command" and "what" in n):
if not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids: if not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids:
raise ValueError("Некорректная реплика или повторяющийся tid") raise ValueError("Некорректная реплика или повторяющийся tid")
+23
View File
@@ -0,0 +1,23 @@
"""Compile the screens/styles of a .rpy file to JSON: python -m tests.make_screens SOURCE.rpy OUT.json"""
import json
import sys
from pathlib import Path
from app.converter.compatibility import audit_ir, require_supported_scripts
from app.converter.pipeline import _used_screens
from app.converter.rpy_parser import RpyParser, ScriptData
def main(source: Path, out: Path) -> None:
data = ScriptData()
RpyParser(data, source.name).parse(source.read_text(encoding="utf-8"))
audit_ir(data)
require_supported_scripts(data)
shipped = {name: data.screens[name] for name in sorted(_used_screens(data))}
out.write_text(json.dumps({"screens": shipped, "styles": data.styles, "atl": data.atl,
"transforms": data.transforms}, ensure_ascii=False, separators=(",", ":")),
encoding="utf-8")
if __name__ == "__main__":
main(Path(sys.argv[1]), Path(sys.argv[2]))
+4 -2
View File
@@ -73,13 +73,15 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
write(tmp_path / "game/script.rpy", source) write(tmp_path / "game/script.rpy", source)
assert inspect_directory(tmp_path)["compatible"] assert inspect_directory(tmp_path)["compatible"]
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused')) write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen unused'))
with pytest.raises(CompatibilityError, match="call screen"): assert inspect_directory(tmp_path)["compatible"] # a defined screen can be called
write(tmp_path / "game/script.rpy", source.replace(' "Hello"', ' call screen missing_screen'))
with pytest.raises(CompatibilityError, match="screen"):
inspect_directory(tmp_path) inspect_directory(tmp_path)
@pytest.mark.parametrize("source,reason", [ @pytest.mark.parametrize("source,reason", [
('init python:\n def helper():\n yield 1\nlabel start:\n "Hi"', "init python"), ('init python:\n def helper():\n yield 1\nlabel start:\n "Hi"', "init python"),
('label start:\n $ result = renpy.show_screen("map")', "renpy.show_screen"), ('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
('label start:\n jump missing', "missing"), ('label start:\n jump missing', "missing"),
]) ])
def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason): def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason):
+161
View File
@@ -0,0 +1,161 @@
import pytest
from app.converter.compatibility import CompatibilityError, audit_ir, require_supported_scripts
from app.converter.rpy_lexer import parse_tree
from app.converter.rpy_parser import RpyParser, ScriptData
HUD = '''
style hud_text:
size 28
color "#fff"
unknown_cosmetic_thing 3
transform pulse:
zoom 1.0
linear 0.5 zoom 1.1
repeat
screen hud(day=1):
zorder 5
tag hud
default open = False
frame:
xalign 0.0 yalign 0.0
background "#0008"
vbox:
spacing 4
text "Day [day]" style "hud_text"
if money > 10:
text "Rich" color "#0f0"
else:
text "Poor"
for item in inventory:
textbutton item action [SetVariable("held", item), Hide("hud")] at pulse
key "K_h" action ToggleScreenVariable("open")
timer 1.0 action NullAction() repeat True
use footer("bye")
screen footer(label):
imagebutton auto "btn_%s.png" action Return(1) hover_sound "tick.ogg"
label start:
$ money = 20
$ inventory = ["key"]
show screen hud(2)
call screen footer("x")
$ r = _return
hide screen hud
return
'''
def parse(source):
data = ScriptData()
RpyParser(data, "script.rpy").parse(source)
return data
def test_screens_styles_and_calls_become_ir():
data = parse(HUD)
assert not data.blockers, data.blockers
hud = data.screens["hud"]
assert hud["params"][0]["n"] == "day"
assert hud["props"]["zorder"] == {"k": "const", "v": 5} and hud["props"]["tag"]["id"] == "hud"
assert hud["defaults"] == [["open", {"k": "const", "v": False}]]
frame = hud["body"][0]
assert frame["w"] == "frame" and frame["props"]["background"]["v"] == "#0008"
box = frame["body"][0]
assert box["w"] == "vbox" and [n.get("w") or n["c"] for n in box["body"]] == ["text", "if", "for"]
button = box["body"][2]["body"][0]
assert button["w"] == "textbutton" and button["props"]["action"]["k"] == "list"
assert button["props"]["at"] == [{"props": {"zoom": 1.0}, "name": "pulse", "atl": "pulse"}]
assert [n.get("c") for n in hud["body"][1:]] == ["key", "timer", "use"]
assert data.styles["hud_text"]["props"]["size"]["v"] == 28
assert "unknown_cosmetic_thing" not in data.styles["hud_text"]["props"]
ops = [n["op"] for label in data.modules["script.rpy"] for n in label["body"] if n["op"].endswith("screen")]
assert ops == ["show_screen", "call_screen", "hide_screen"]
call = next(n for label in data.modules["script.rpy"] for n in label["body"] if n["op"] == "call_screen")
assert call["name"] == "footer" and call["args"][0] == {"k": "const", "v": "x"}
def test_shown_screens_are_audited_but_unshown_ones_are_not():
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n "Hi"\n')
audit_ir(data)
require_supported_scripts(data) # never shown: harmless
data = parse('screen odd():\n mousearea:\n pass\nlabel start:\n call screen odd\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="screen"):
require_supported_scripts(data)
def test_unknown_actions_and_screens_block_the_import():
data = parse('screen s():\n textbutton "x" action Preference("fullscreen")\nlabel start:\n call screen s\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="Preference"):
require_supported_scripts(data)
data = parse('label start:\n call screen nowhere\n')
audit_ir(data)
with pytest.raises(CompatibilityError, match="screen"):
require_supported_scripts(data)
def test_screens_opened_from_python_and_by_actions_are_shipped():
from app.converter.pipeline import _used_screens
data = parse(
'screen a():\n textbutton "x" action Show("b")\nscreen b():\n text "B"\nscreen c():\n text "C"\n'
'screen d():\n text "D"\nlabel start:\n $ renpy.show_screen("d")\n call screen a\n')
audit_ir(data)
require_supported_scripts(data)
assert _used_screens(data) == {"a", "b", "d"}
def test_touch_variants_are_dropped_and_later_definitions_win():
data = parse(
'screen s():\n text "one"\nscreen s():\n variant "touch"\n text "touch"\n'
'screen t():\n text "one"\nscreen t():\n text "two"\nlabel start:\n "x"\n')
assert data.screens["s"]["body"][0]["args"][0]["v"] == "one"
assert data.screens["t"]["body"][0]["args"][0]["v"] == "two"
def test_lexer_keeps_screen_blocks_nested():
lines = parse_tree("screen s():\n vbox:\n text \"a\"\n")
assert lines[0].block[0].block[0].text.strip().startswith("text")
def test_decompiled_forms_operators_glued_properties_has_transclude_and_inline_atl():
data = parse(
'transform blink(delay, cycle=1.0):\n alpha 0.0\n pause delay\n linear cycle alpha 1.0\n'
'screen card(title):\n frame:\n has vbox\n text title\n transclude\n'
'screen deck(day):\n use card("T"):\n text "Day " + str(day) size 20\n'
' text "x" pos(5, 6) xysize(10,20)\n'
' add "bg.png":\n alpha 0.0\n linear 0.5 alpha 1.0\n'
' add "dot.png" at blink(0.2, 0.3)\n'
' on "show" action NullAction()\n'
'label start:\n call screen deck(1)\n')
audit_ir(data)
require_supported_scripts(data)
deck = data.screens["deck"]["body"]
use = deck[0]
assert use["c"] == "use" and use["body"][0]["args"][0]["k"] == "bin"
assert use["body"][0]["props"]["size"]["v"] == 20
card = data.screens["card"]["body"][0]
assert card["w"] == "frame" and [n.get("w") or n["c"] for n in card["body"][0]["body"]] == ["text", "transclude"]
assert deck[1]["props"]["pos"]["k"] == "tuple" and deck[1]["props"]["xysize"]["k"] == "tuple"
assert deck[2]["props"]["alpha"] == {"k": "const", "v": 0.0}
animated = deck[2]["props"]["at"][0]
assert animated["atl"]["steps"][-1]["op"] == "move" and animated["atl"]["steps"][-1]["props"] == {"alpha": 1.0}
called = deck[3]["props"]["at"][0]
assert called["name"] == "blink" and called["atl"] == "blink" and len(called["args"]) == 2
blink = data.atl["blink"]
assert [p["n"] for p in blink["params"]] == ["delay", "cycle"]
assert blink["steps"][1] == {"op": "pause", "t": {"e": {"k": "name", "id": "delay"}}}
assert deck[4] == {"c": "on", "event": "show", "action": {"k": "call", "f": {"k": "name", "id": "NullAction"}, "a": [], "kw": {}}}
def test_computed_atl_values_make_a_transform_animated():
data = parse('transform shift(x):\n xoffset x\nlabel start:\n "Hi"\n')
block = data.atl["shift"]
assert block["params"][0]["n"] == "x"
assert block["steps"][0]["props"]["xoffset"] == {"e": {"k": "name", "id": "x"}}
assert data.transforms["shift"] == {}
+2 -2
View File
@@ -145,8 +145,8 @@ LOCKED`), Redis не нужен.
Планы: Планы:
* поддержка `screen`-языка и ATL `function`/`matrixcolor`/шейдеров (базовый ATL * ATL `function`, `matrixcolor`, шейдеры и `im.*` (экраны и ATL с движением уже есть:
с движением уже есть: `backend/app/converter/atl.py`, `frontend/src/player/atl.ts`); [SCREENS.md](SCREENS.md), `backend/app/converter/atl.py`, `frontend/src/player/atl.ts`);
* другие движки (TyranoBuilder, RPG Maker MV) через тот же IR; * другие движки (TyranoBuilder, RPG Maker MV) через тот же IR;
* дальнейшее дробление `engine.ts` на модули (рендер сцены, UI, сохранения); * дальнейшее дробление `engine.ts` на модули (рендер сцены, UI, сохранения);
* типизация backend под `mypy` (сейчас проверка справочная). * типизация backend под `mypy` (сейчас проверка справочная).
+50
View File
@@ -0,0 +1,50 @@
# Экраны Ren'Py (screen)
Игры показывают свой интерфейс (HUD, карты-хотспоты, телефон, инвентарь) экранами
`screen`. Конвертер переводит их в IR, плеер строит из IR DOM и пересчитывает его при
каждом изменении состояния. Выражения выполняются в той же песочнице Python, что и сценарий.
* разбор: `backend/app/converter/screens.py` (язык экранов), `rpy_parser.py` (`screen`, `style`,
`call/show/hide screen`), `atl.py` (анимации виджетов);
* плеер: `frontend/src/player/screens.ts`, подключение — в `engine.ts`.
## Что поддерживается
* **Операторы сценария:** `call screen name(args)` (ждёт `Return`, значение в `_return`),
`show screen`, `hide screen`; из Python — `renpy.show_screen`, `renpy.hide_screen`.
* **Контейнеры:** `fixed`, `frame`, `window`, `vbox`, `hbox`, `grid`, `vpgrid`, `viewport`
(прокрутка), `button`, `has`.
* **Элементы:** `text`, `label`, `textbutton`, `imagebutton` (`idle`/`hover`/`insensitive`/`auto`),
`add`, `null`, `bar`/`vbar` (отображение и перетаскивание для `VariableValue`).
* **Управление:** `if`/`elif`/`else`, `showif`, `for`, `use` (в том числе с `transclude`),
`default`, `$` и `python:`, `key`, `timer`, `on "show"`/`"hide"`.
* **Свойства:** размеры, позиции и якоря, отступы, `spacing`, `background`/`foreground`, `crop`,
цвета и шрифты текста, `outlines`, `alpha`, `zoom`, `rotate`, `blur`, `selected`, `sensitive`,
`tooltip` (`GetTooltip()`), `hover_sound`/`activate_sound`, `at` с анимированными transform,
условные свойства (`if` внутри виджета, который только задаёт свойства).
* **Стили:** `style name is parent:` и `style_prefix`; состояния `idle_`/`hover_`/`selected_`/
`insensitive_`. Неизвестные декоративные свойства стилей молча отбрасываются.
* **Действия:** `Jump`, `Call`, `Return`, `SetVariable`, `ToggleVariable`, `SetScreenVariable`,
`ToggleScreenVariable`, `SetDict`, `SetField`, `ToggleField`, `Show`, `Hide`, `ToggleScreen`,
`Function`, `Play`, `Stop`, `Notify`, `If`, `SelectedIf`, `SensitiveIf`, `NullAction`, `Language`,
`ShowMenu` (открывает меню плеера), `OpenURL` (только http/https), `Quit`, `MainMenu`.
* **Изображения-значения:** `Crop`, `LiveCrop`, `Solid`, `Frame`, `Null`, `Transform`,
`DynamicDisplayable`, `Color`.
Стандартные экраны движка (`say`, `choice`, `input`, главное меню, настройки, сохранения, история)
плеер не использует: у него свой интерфейс. `ShowMenu("preferences")` открывает его меню.
## Правила импорта
Экран проверяется только если игра его показывает (`call/show screen`, `Show("name")`,
`renpy.show_screen("name")`, `use name`). Неподдерживаемое внутри такого экрана останавливает
импорт, внутри неиспользуемого (например, стандартного) — нет. Экран, который вызывают, но нигде
не определили, тоже останавливает импорт. Экраны для `variant "touch"`/`"phone"` отбрасываются.
## Ограничения
* Не поддерживаются `input`, `imagemap`/`hotspot`, `mousearea`, `drag`/`draggroup`, `side`,
`matrixcolor`, шейдеры и `im.*`; такие экраны блокируют импорт.
* Экран перестраивается целиком; положение прокрутки сохраняется, фокус и ввод — нет.
* Позиции без точки (`xpos 1`) читаются как доля, а целые больше 1 — как пиксели.
* Фокус с клавиатуры — только Tab и Enter/Space на кнопках.
+302
View File
@@ -16,10 +16,18 @@
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.3.4",
"linkedom": "0.18.14",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "5.4.11" "vite": "5.4.11"
} }
}, },
"node_modules/@acemir/cssom": {
"version": "0.9.32",
"resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.32.tgz",
"integrity": "sha512-OijrAUu7rrJhSIoHNg6LMPS5e344nwUHA+1EFYYjrh64D+GmytUsfBQjci45TjT+rd5Spsp8B5xsZDT+TgyXXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.7", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
@@ -1232,6 +1240,20 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/boolbase": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
"integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.29.4", "version": "4.29.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz",
@@ -1294,6 +1316,41 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/css-select": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
"integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0",
"css-what": "^8.0.0",
"domhandler": "^6.0.1",
"domutils": "^4.0.2",
"nth-check": "^3.0.1"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/css-what": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
"integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -1319,6 +1376,77 @@
} }
} }
}, },
"node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.464", "version": "1.5.464",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz",
@@ -1326,6 +1454,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
@@ -1400,6 +1541,118 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
"integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
"dev": true,
"license": "MIT"
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/htmlparser2/node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1432,6 +1685,31 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/linkedom": {
"version": "0.18.14",
"resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.14.tgz",
"integrity": "sha512-BRUnkOm4YR5vo+qI3j0EbungB0vZPSCue5AdnTQRaxAGSbsOZiBFRO0rh4zlo8qXUhli8pxvnpEXDklMnlq2Rg==",
"dev": true,
"license": "ISC",
"dependencies": {
"@acemir/cssom": "^0.9.32",
"css-select": "^7.0.0",
"html-escaper": "^3.0.3",
"htmlparser2": "^10.1.0",
"uhyphen": "^0.2.0"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"canvas": ">= 2"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1490,6 +1768,23 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nth-check": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
"integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^2.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1682,6 +1977,13 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/uhyphen": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
"integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
"dev": true,
"license": "ISC"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.3.4", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
+1
View File
@@ -18,6 +18,7 @@
"@types/react": "18.3.12", "@types/react": "18.3.12",
"@types/react-dom": "18.3.1", "@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4", "@vitejs/plugin-react": "4.3.4",
"linkedom": "0.18.14",
"typescript": "5.6.3", "typescript": "5.6.3",
"vite": "5.4.11" "vite": "5.4.11"
} }
+66 -22
View File
@@ -2,17 +2,22 @@
export type AtlProps = Record<string, any>; export type AtlProps = Record<string, any>;
/** A literal, or an expression evaluated when the timeline runs (`{ e: expr }`). */
export type AtlValue<T = any> = T | { e: any };
export interface AtlBlock { export interface AtlBlock {
steps: AtlStep[]; steps: AtlStep[];
repeat: null | true | number; repeat: null | true | AtlValue<number>;
/** Parameters of `transform name(a, b=1):`. */
params?: { n: string; d: any; kind: string }[];
} }
export type AtlStep = export type AtlStep =
| { op: "set"; props: AtlProps } | { op: "set"; props: AtlProps }
| { op: "move"; warp: "linear" | "ease" | "easein" | "easeout"; t: number; props: AtlProps } | { op: "move"; warp: "linear" | "ease" | "easein" | "easeout"; t: AtlValue<number>; props: AtlProps }
| { op: "pause"; t: number } | { op: "pause"; t: AtlValue<number> }
| { op: "parallel"; blocks: AtlBlock[] } | { op: "parallel"; blocks: AtlBlock[] }
| { op: "choice"; blocks: { w: number; block: AtlBlock }[] } | { op: "choice"; blocks: { w: AtlValue<number>; block: AtlBlock }[] }
| { op: "block"; block: AtlBlock } | { op: "block"; block: AtlBlock }
| { op: "on"; events: string[]; block: AtlBlock } | { op: "on"; events: string[]; block: AtlBlock }
| { op: "use"; name: string }; | { op: "use"; name: string };
@@ -24,6 +29,20 @@ export interface AtlHost {
props(name: string): AtlProps | null; props(name: string): AtlProps | null;
/** Called after every change of the working properties. */ /** Called after every change of the working properties. */
apply(props: AtlProps): void; apply(props: AtlProps): void;
/** Evaluate a run-time value with the transform's parameters in scope. */
eval?(expr: any, vars: Map<string, any>): any;
}
/** Bind `name(args)` of an `at` clause to the transform's parameters. `ev` evaluates in the caller's scope. */
export function bindArguments(block: AtlBlock, spec: { args?: any[]; kw?: Record<string, any> },
ev: (expr: any) => any): Map<string, any> {
const vars = new Map<string, any>();
(block.params ?? []).forEach((p, i) => {
if (spec.kw && Object.hasOwn(spec.kw, p.n)) vars.set(p.n, ev(spec.kw[p.n]));
else if (spec.args && i < spec.args.length) vars.set(p.n, ev(spec.args[i]));
else vars.set(p.n, p.d ? ev(p.d) : null);
});
return vars;
} }
const SHOW_EVENTS = new Set(["show", "appear", "default", "start", "update", "replace"]); const SHOW_EVENTS = new Set(["show", "appear", "default", "start", "update", "replace"]);
@@ -50,15 +69,34 @@ function nextFrame() {
export class AtlRunner { export class AtlRunner {
private token = 0; private token = 0;
constructor(private host: AtlHost, private props: AtlProps) {} constructor(private host: AtlHost, private props: AtlProps, private vars: Map<string, any> = new Map()) {}
/** Start the timeline; resolves when it ends (never, for endless loops) or is stopped. */ /** Resolve `{ e: expr }` values (also inside tuples). */
async start(block: AtlBlock): Promise<void> { private val<T = any>(v: AtlValue<T> | undefined): any {
if (Array.isArray(v)) return v.map((x) => this.val(x));
if (v && typeof v === "object" && "e" in v) return this.host.eval ? this.host.eval((v as any).e, this.vars) : null;
return v;
}
private props_(values: AtlProps): AtlProps {
return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, this.val(v)]));
}
private seconds(v: AtlValue<number> | undefined): number {
const n = Number(this.val(v));
return Number.isFinite(n) ? Math.max(0, n) : 0;
}
/** Start the timeline; resolves when it ends (never, for endless loops) or is stopped.
* The result is true only when the timeline ran to its end. */
async start(block: AtlBlock): Promise<boolean> {
const token = ++this.token; const token = ++this.token;
try { try {
await this.run(block, token, 0, "show"); await this.run(block, token, 0, "show");
return true;
} catch (err) { } catch (err) {
if (!(err instanceof Stopped)) throw err; if (!(err instanceof Stopped)) throw err;
return false;
} }
} }
@@ -126,7 +164,7 @@ export class AtlRunner {
} }
loops++; loops++;
if (block.repeat === null) return; if (block.repeat === null) return;
if (block.repeat !== true && loops >= block.repeat) return; if (block.repeat !== true && loops >= this.seconds(block.repeat as AtlValue<number>)) return;
// a loop without any waiting step would freeze the page: yield one frame per turn // a loop without any waiting step would freeze the page: yield one frame per turn
if (!waited && performance.now() - startedAt < 1) await nextFrame(); if (!waited && performance.now() - startedAt < 1) await nextFrame();
} }
@@ -136,22 +174,27 @@ export class AtlRunner {
private async step(step: AtlStep, token: number, depth: number, event: string): Promise<boolean> { private async step(step: AtlStep, token: number, depth: number, event: string): Promise<boolean> {
switch (step.op) { switch (step.op) {
case "set": case "set":
Object.assign(this.props, step.props); Object.assign(this.props, this.props_(step.props));
this.apply(); this.apply();
return false; return false;
case "pause": case "pause": {
await this.sleep(step.t, token); const t = this.seconds(step.t);
return step.t > 0; await this.sleep(t, token);
case "move": return t > 0;
await this.move(step, token); }
return step.t > 0; case "move": {
const t = this.seconds(step.t);
await this.move(step, t, token);
return t > 0;
}
case "parallel": case "parallel":
await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event))); await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event)));
return true; return true;
case "choice": { case "choice": {
const total = step.blocks.reduce((s, b) => s + b.w, 0); const weights = step.blocks.map((b) => this.seconds(b.w) || 1);
const total = weights.reduce((s, w) => s + w, 0);
let pick = Math.random() * total; let pick = Math.random() * total;
const chosen = step.blocks.find((b) => (pick -= b.w) < 0) ?? step.blocks[step.blocks.length - 1]; const chosen = step.blocks.find((_, i) => (pick -= weights[i]) < 0) ?? step.blocks[step.blocks.length - 1];
if (chosen) await this.run(chosen.block, token, depth + 1, event); if (chosen) await this.run(chosen.block, token, depth + 1, event);
return true; return true;
} }
@@ -199,22 +242,23 @@ export class AtlRunner {
} }
} }
private async move(step: Extract<AtlStep, { op: "move" }>, token: number) { private async move(step: Extract<AtlStep, { op: "move" }>, seconds: number, token: number) {
const target = this.props_(step.props);
const from: AtlProps = {}; const from: AtlProps = {};
for (const key of Object.keys(step.props)) from[key] = this.current(key); for (const key of Object.keys(target)) from[key] = this.current(key);
const curve = curves[step.warp] ?? curves.linear; const curve = curves[step.warp] ?? curves.linear;
const start = performance.now(); const start = performance.now();
const duration = step.t * 1000; const duration = seconds * 1000;
while (duration > 0) { while (duration > 0) {
await nextFrame(); await nextFrame();
this.alive(token); this.alive(token);
const raw = Math.min(1, (performance.now() - start) / duration); const raw = Math.min(1, (performance.now() - start) / duration);
const k = curve(raw); const k = curve(raw);
for (const [key, to] of Object.entries(step.props)) this.props[key] = mix(from[key], to, k); for (const [key, to] of Object.entries(target)) this.props[key] = mix(from[key], to, k);
this.apply(); this.apply();
if (raw >= 1) return; if (raw >= 1) return;
} }
Object.assign(this.props, step.props); Object.assign(this.props, target);
this.apply(); this.apply();
} }
} }
+123 -3
View File
@@ -6,6 +6,7 @@ import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n"; import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel"; import { naniText } from "./naninovel";
import { ScreenManager, type ScreenHost } from "./screens";
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util"; import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
import type { import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
@@ -35,6 +36,9 @@ export class Engine {
private stack: Frame[] = []; private stack: Frame[] = [];
private gen = 0; private gen = 0;
private waiters: (() => void)[] = []; private waiters: (() => void)[] = [];
private screens: ScreenManager;
private screenFonts = new Map<string, string>();
private seenImages = new Set<string>();
private running = false; private running = false;
private skip = false; private skip = false;
@@ -101,7 +105,8 @@ export class Engine {
del: (n) => void delete this.store[n], del: (n) => void delete this.store[n],
}; };
this.py = new Python(scope); this.py = new Python(scope);
this.builtins = this.makeBuiltins(); this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {});
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins() };
const [w, h] = game.resolution; const [w, h] = game.resolution;
this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306); this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306);
@@ -113,6 +118,15 @@ export class Engine {
evalCond: (c) => truthy(this.safeEval(c)), evalCond: (c) => truthy(this.safeEval(c)),
transform: (name) => game.transforms[name] ?? null, transform: (name) => game.transforms[name] ?? null,
atl: (name) => game.atl?.[name] ?? null, atl: (name) => game.atl?.[name] ?? null,
evalExpr: (expr, frame) => {
const saved = this.py.locals;
this.py.locals = frame ? [...saved, frame] : saved;
try {
return this.safeEval(expr);
} finally {
this.py.locals = saved;
}
},
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))), text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
}); });
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {}); this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
@@ -125,6 +139,7 @@ export class Engine {
} }
destroy() { destroy() {
this.screens.destroy();
this.gen++; this.gen++;
this.wakeAll(); this.wakeAll();
this.audio.stopAll(); this.audio.stopAll();
@@ -303,8 +318,12 @@ export class Engine {
throw new JumpSignal("jump", "__quit__"); throw new JumpSignal("jump", "__quit__");
}, },
version: () => "pgvn", version: () => "pgvn",
...this.screens.renpyApi(),
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with` // presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
end_replay: () => null, end_replay: () => null,
variant: (name: string) => ["pc", "large", "web", "medium"].includes(String(name)),
change_language: (code: any) => void this.setLanguage(code === null || code === undefined ? this.game.sourceLang ?? "und" : String(code)),
seen_image: (name: any) => this.seenImages.has(String(name)),
block_rollback: () => null, block_rollback: () => null,
transition: () => null, transition: () => null,
}; };
@@ -330,6 +349,10 @@ export class Engine {
random, random,
math: { floor: Math.floor, ceil: Math.ceil, sqrt: Math.sqrt, pi: Math.PI, pow: Math.pow }, math: { floor: Math.floor, ceil: Math.ceil, sqrt: Math.sqrt, pi: Math.PI, pow: Math.pow },
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0), len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
// a read-only view: `"name" in globals()` works, writes do not reach the game
globals: () => new PyDict(Object.entries(this.store)),
...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
"Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])),
str: (x: any) => pyStr(x), str: (x: any) => pyStr(x),
int: (x: any) => Math.trunc(num(x)), int: (x: any) => Math.trunc(num(x)),
float: (x: any) => num(x), float: (x: any) => num(x),
@@ -385,7 +408,72 @@ export class Engine {
} }
} }
private screenHost(): ScreenHost {
const engine = this;
return {
py: this.py,
fileUrl: (path) => this.fileUrl(path),
image: (value) => this.screenImage(value),
text: (el, value, size) => renderText(el, parseText(this.interp(this.tr.str(value))), Infinity, size),
fontFamily: (file) => this.screenFont(file),
get baseSize() { return engine.baseSize; },
play: (channel, file, loop) => this.audio.play(channel, [file], { loop }),
stop: (channel) => this.audio.stop(channel, 0),
notify: (message) => this.notify(message),
jump: (label) => this.restart(() => this.guardedJump(label)),
call: (label) => this.restart(() => this.call(label)),
quit: () => this.onExit(),
mainMenu: () => this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
openMenu: () => this.openGameMenu(),
openUrl: (url) => {
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
},
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"),
transform: (name) => this.game.transforms[name] ?? null,
atl: (name) => this.game.atl?.[name] ?? null,
};
}
/** A jump requested from a widget; the special menu/quit targets end the session instead. */
private guardedJump(label: string) {
try {
this.jump(label);
} catch (err) {
if (!(err instanceof Restart)) throw err;
this.cur = { label: "", path: [0] };
if (err.message === "__quit__") this.onExit();
}
}
private screenImage(value: string): HTMLElement | null {
if (/^#[0-9a-f]{3,8}$/i.test(value)) {
const box = el("div");
box.style.background = value;
box.style.width = box.style.height = "100%";
return box;
}
const resolved = this.resolveImage(value.split(" "));
if (resolved) return this.renderer.displayable(resolved.def);
const url = this.fileUrl(value);
if (!url) return null;
const img = new Image();
img.src = url;
img.draggable = false;
return img;
}
private screenFont(file: string): string | null {
let family = this.screenFonts.get(file);
if (!family) {
family = `pg-scr-${this.screenFonts.size}`;
this.screenFonts.set(file, family);
this.loadFont(family, file);
}
return family;
}
private resetStore() { private resetStore() {
this.screens.clear();
this.store = {}; this.store = {};
try { try {
this.py.exec(this.game.init); this.py.exec(this.game.init);
@@ -557,6 +645,7 @@ export class Engine {
} }
try { try {
await this.exec(node); await this.exec(node);
this.screens.refresh();
} catch (err) { } catch (err) {
if (err instanceof JumpSignal) { if (err instanceof JumpSignal) {
if (err.kind === "jump") this.jump(err.target); if (err.kind === "jump") this.jump(err.target);
@@ -577,6 +666,13 @@ export class Engine {
} }
} }
private screenArgs(node: Node) {
return {
args: (node.args ?? []).map((e: Expr) => this.safeEval(e)),
kw: Object.fromEntries(Object.entries(node.kw ?? {}).map(([k, e]) => [k, this.safeEval(e as Expr)])),
};
}
private async exec(node: Node) { private async exec(node: Node) {
switch (node.op) { switch (node.op) {
case "nani_exit": case "nani_exit":
@@ -688,6 +784,26 @@ export class Engine {
return this.jump(node.target ?? pyStr(this.safeEval(node.expr))); return this.jump(node.target ?? pyStr(this.safeEval(node.expr)));
case "call": case "call":
return this.call(node.target ?? pyStr(this.safeEval(node.expr)), node.args); return this.call(node.target ?? pyStr(this.safeEval(node.expr)), node.args);
case "call_screen": {
const { args, kw } = this.screenArgs(node);
this.interactionStart();
this.setSkip(false);
const value = await this.wait<any>((done) => this.screens.show(node.name, args, kw, { call: true, onReturn: done }));
this.screens.hideCall(node.name);
this.store._return = value ?? null;
this.advance();
return;
}
case "show_screen": {
const { args, kw } = this.screenArgs(node);
this.screens.show(node.name, args, kw);
this.advance();
return;
}
case "hide_screen":
this.screens.hide(node.name);
this.advance();
return;
case "camera": case "camera":
this.renderer.camera(node.at ?? []); this.renderer.camera(node.at ?? []);
this.advance(); this.advance();
@@ -727,6 +843,7 @@ export class Engine {
return; return;
} }
const tag = node.as ?? (node.img.name ? node.img.name[0] : img.name[0]); const tag = node.as ?? (node.img.name ? node.img.name[0] : img.name[0]);
for (let n = 1; n <= img.name.length; n++) this.seenImages.add(img.name.slice(0, n).join(" "));
const zorder = node.zorder ? Number(this.safeEval(node.zorder)) : undefined; const zorder = node.zorder ? Number(this.safeEval(node.zorder)) : undefined;
this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind }); this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind });
} }
@@ -1003,6 +1120,7 @@ export class Engine {
this.wakeAll(); this.wakeAll();
this.onAdvance = null; this.onAdvance = null;
this.skipTick = null; this.skipTick = null;
this.screens.hideCalls();
this.closeScreen(); this.closeScreen();
this.menuEl.classList.add("pg-hidden"); this.menuEl.classList.add("pg-hidden");
this.centeredEl.classList.add("pg-hidden"); this.centeredEl.classList.add("pg-hidden");
@@ -1097,6 +1215,7 @@ export class Engine {
cur: anchor(this.cur), cur: anchor(this.cur),
stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })), stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })),
store: encodeValue(this.store), store: encodeValue(this.store),
screens: encodeValue(this.screens.snapshot()),
scene: this.renderer.snapshot(), scene: this.renderer.snapshot(),
music: this.audio.current("music"), music: this.audio.current("music"),
window: this.windowShown, window: this.windowShown,
@@ -1142,6 +1261,7 @@ export class Engine {
this.restart(() => { this.restart(() => {
this.resetStore(); this.resetStore();
Object.assign(this.store, decodeValue(data.store)); Object.assign(this.store, decodeValue(data.store));
this.screens.restore(decodeValue(data.screens ?? []));
this.renderer.scene(); this.renderer.scene();
for (const s of data.scene) { for (const s of data.scene) {
const img = this.resolveImage(s.name); const img = this.resolveImage(s.name);
@@ -1239,7 +1359,7 @@ export class Engine {
this.centeredEl = el("div", "pg-centered pg-hidden"); this.centeredEl = el("div", "pg-centered pg-hidden");
this.screen = el("div", "pg-screen pg-hidden"); this.screen = el("div", "pg-screen pg-hidden");
this.notifyEl = el("div", "pg-notify pg-hidden"); this.notifyEl = el("div", "pg-notify pg-hidden");
stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screen, this.notifyEl); stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screens.layer, this.screen, this.notifyEl);
// touch-friendly menu button outside the scaled stage // touch-friendly menu button outside the scaled stage
const hud = el("button", "pg-hud-btn", "☰"); const hud = el("button", "pg-hud-btn", "☰");
@@ -1255,7 +1375,7 @@ export class Engine {
stage.addEventListener("click", (ev) => { stage.addEventListener("click", (ev) => {
if (!this.screen.classList.contains("pg-hidden")) return; if (!this.screen.classList.contains("pg-hidden")) return;
if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick")) return; if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick, .pg-sbtn, .pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-smodal")) return;
this.advanceInput(); this.advanceInput();
}); });
stage.addEventListener("wheel", (ev) => { stage.addEventListener("wheel", (ev) => {
+17
View File
@@ -343,3 +343,20 @@
.pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; } .pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; }
.pg-dialog button { font-size: 15px; padding: 9px 18px; } .pg-dialog button { font-size: 15px; padding: 9px 18px; }
.pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; } .pg-dialog .pg-primary { background: #ff9f43; border-color: #ff9f43; color: #1a1205; font-weight: 650; }
/* --- game screens (Ren'Py screen language) ------------------------------------------ */
.pg-usr { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst { position: absolute; inset: 0; pointer-events: none; }
.pg-sinst.pg-smodal { pointer-events: auto; }
.pg-sw { pointer-events: none; min-width: 0; min-height: 0; color: var(--pg-text, #fff); font-size: var(--pg-font, 24px); }
.pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-sbtn { pointer-events: auto; }
.pg-sbtn { cursor: pointer; user-select: none; }
.pg-sbtn.pg-sdisabled { cursor: default; }
.pg-sbtn:focus-visible { outline: 2px solid var(--pg-accent, #4af); outline-offset: 2px; }
.pg-simgbtn img, .pg-simgbtn .pg-simg { display: block; pointer-events: none; }
.pg-stext { white-space: pre-wrap; }
.pg-sbar { background: rgba(255, 255, 255, 0.18); min-width: 40px; min-height: 12px; }
.pg-sbar-fill { background: var(--pg-accent, #4af); width: 100%; height: 100%; }
.pg-sbar:not(.pg-svbar) .pg-sbar-fill { height: 100%; }
.pg-sadd { pointer-events: none; }
.pg-sviewport { scrollbar-width: thin; }
+14 -3
View File
@@ -1,7 +1,7 @@
// DOM renderer for the image layer: one absolutely positioned element per shown tag. // DOM renderer for the image layer: one absolutely positioned element per shown tag.
// The stage has the game's native resolution and is scaled with a CSS transform. // The stage has the game's native resolution and is scaled with a CSS transform.
import { AtlRunner, type AtlBlock } from "./atl"; import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types"; import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = { const POSITIONS: Record<string, Record<string, number>> = {
@@ -29,6 +29,8 @@ export interface RendererHost {
evalCond(e: any): boolean; evalCond(e: any): boolean;
transform(name: string): Record<string, any> | null; transform(name: string): Record<string, any> | null;
atl(name: string): AtlBlock | null; atl(name: string): AtlBlock | null;
/** Evaluate an expression, optionally with extra variables (ATL parameters) in scope. */
evalExpr(expr: any, frame?: Map<string, any>): any;
text(e: any): string; text(e: any): string;
} }
@@ -105,7 +107,8 @@ export class Renderer {
block: (name) => this.host.atl(name), block: (name) => this.host.atl(name),
props: (name) => this.host.transform(name), props: (name) => this.host.transform(name),
apply: (p) => this.style(entry.el, { ...p }), apply: (p) => this.style(entry.el, { ...p }),
}, live); eval: (expr, frame) => this.host.evalExpr(expr, frame),
}, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)));
entry.runners.push({ runner, block }); entry.runners.push({ runner, block });
void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err)); void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err));
} }
@@ -207,6 +210,11 @@ export class Renderer {
return document.createElement("div"); return document.createElement("div");
} }
/** An element for an image definition, for UI built outside the scene (screens). */
displayable(def: ImageDef): HTMLElement {
return this.build(def);
}
/** Frame-by-frame ATL animation: swaps children on timers while attached. */ /** Frame-by-frame ATL animation: swaps children on timers while attached. */
private animated(def: ImageDef, depth: number): HTMLElement { private animated(def: ImageDef, depth: number): HTMLElement {
const box = document.createElement("div"); const box = document.createElement("div");
@@ -311,7 +319,10 @@ export class Renderer {
if (ax.endsWith("px") || ay.endsWith("px")) if (ax.endsWith("px") || ay.endsWith("px"))
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`; el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
if (p.alpha !== undefined) el.style.opacity = String(p.alpha); if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (p.filter) el.style.filter = `${p.filter}(1)`; const filters: string[] = [];
if (p.filter) filters.push(`${p.filter}(1)`);
if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`);
if (filters.length) el.style.filter = filters.join(" ");
} }
// --- transitions --------------------------------------------------------------- // --- transitions ---------------------------------------------------------------
File diff suppressed because it is too large. Load diff
+6
View File
@@ -53,6 +53,9 @@ export interface AtSpec {
props?: Record<string, any>; props?: Record<string, any>;
/** Animated transform: a name from GameData.atl or an inline timeline. */ /** Animated transform: a name from GameData.atl or an inline timeline. */
atl?: string | import("./atl").AtlBlock; atl?: string | import("./atl").AtlBlock;
/** Arguments of `at name(args)` for a parameterised transform. */
args?: Expr[];
kw?: Record<string, Expr>;
effect?: "shake"; effect?: "shake";
d?: number; d?: number;
dist?: number; dist?: number;
@@ -109,6 +112,8 @@ export interface GameData {
images: Record<string, ImageDef>; images: Record<string, ImageDef>;
transforms: Record<string, Record<string, any>>; transforms: Record<string, Record<string, any>>;
atl?: Record<string, import("./atl").AtlBlock>; atl?: Record<string, import("./atl").AtlBlock>;
screens?: Record<string, import("./screens").ScreenDef>;
styles?: Record<string, import("./screens").StyleDef>;
transitions: Record<string, Transition | null>; transitions: Record<string, Transition | null>;
audio: Record<string, string>; audio: Record<string, string>;
channels?: Record<string, { mixer: string; loop: boolean }>; channels?: Record<string, { mixer: string; loop: boolean }>;
@@ -155,4 +160,5 @@ export interface SaveData {
camera?: AtSpec[]; camera?: AtSpec[];
menuSeen?: string[]; menuSeen?: string[];
text?: string; text?: string;
screens?: { name: string; args: any[]; kw: Record<string, any> }[];
} }
+1
View File
@@ -0,0 +1 @@
{"screens":{"card":{"params":[{"n":"title","d":null,"kind":"p"}],"props":{},"defaults":[],"body":[{"w":"frame","args":[],"props":{},"body":[{"w":"vbox","args":[],"props":{},"body":[{"w":"text","args":[{"k":"name","id":"title"}],"props":{},"body":[]},{"c":"transclude"}]}]}]},"counter":{"params":[],"props":{},"defaults":[],"body":[{"w":"add","args":[{"k":"const","v":"#fff"}],"props":{"xysize":{"k":"tuple","v":[{"k":"const","v":10},{"k":"const","v":10}]}},"body":[]},{"w":"bar","args":[],"props":{"value":{"k":"call","f":{"k":"name","id":"VariableValue"},"a":[{"k":"const","v":"score"},{"k":"const","v":10}],"kw":{}},"xsize":{"k":"const","v":100},"vertical":{"k":"const","v":false}},"body":[]}]},"deck":{"params":[],"props":{},"defaults":[],"body":[{"c":"use","name":"card","args":[{"k":"const","v":"Deck"}],"kw":{},"body":[{"w":"text","args":[{"k":"bin","op":"+","l":{"k":"const","v":"child "},"r":{"k":"const","v":"one"}}],"props":{},"body":[]},{"w":"textbutton","args":[{"k":"const","v":"Pick"}],"props":{"action":{"k":"call","f":{"k":"name","id":"Return"},"a":[{"k":"const","v":1}],"kw":{}}},"body":[]}]},{"w":"add","args":[{"k":"const","v":"pic.png"}],"props":{"crop":{"k":"tuple","v":[{"k":"const","v":1},{"k":"const","v":2},{"k":"const","v":30},{"k":"const","v":40}]}},"body":[]},{"w":"add","args":[{"k":"const","v":"dot.png"}],"props":{"at":[{"props":{"alpha":0.0},"name":"blink","atl":"blink","args":[{"k":"const","v":0.02},{"k":"const","v":0.02}],"kw":{}}]},"body":[]},{"w":"add","args":[{"k":"const","v":"bg.png"}],"props":{"alpha":{"k":"const","v":0.0},"at":[{"props":{},"atl":{"steps":[{"op":"move","warp":"linear","t":0.1,"props":{"alpha":1.0}}],"repeat":null}}]},"body":[]},{"c":"on","event":"show","action":{"k":"call","f":{"k":"name","id":"SetVariable"},"a":[{"k":"const","v":"shown"},{"k":"const","v":true}],"kw":{}}},{"c":"on","event":"hide","action":{"k":"call","f":{"k":"name","id":"SetVariable"},"a":[{"k":"const","v":"hid"},{"k":"const","v":true}],"kw":{}}},{"w":"text","args":[{"k":"const","v":"x"}],"props":{"pos":{"k":"tuple","v":[{"k":"const","v":5},{"k":"const","v":6}]}},"body":[]},{"w":"vpgrid","args":[],"props":{"cols":{"k":"const","v":2}},"body":[{"w":"text","args":[{"k":"const","v":"a"}],"props":{},"body":[]},{"w":"text","args":[{"k":"const","v":"b"}],"props":{},"body":[]}]},{"w":"textbutton","args":[{"k":"const","v":"Language"}],"props":{"action":{"k":"list","v":[{"k":"call","f":{"k":"name","id":"Language"},"a":[{"k":"const","v":"ru"}],"kw":{}},{"k":"call","f":{"k":"name","id":"ToggleScreen"},"a":[{"k":"const","v":"counter"}],"kw":{}}]}},"body":[]},{"w":"add","args":[{"k":"call","f":{"k":"name","id":"Crop"},"a":[{"k":"tuple","v":[{"k":"const","v":0},{"k":"const","v":0},{"k":"const","v":8},{"k":"const","v":8}]},{"k":"const","v":"sprite.png"}],"kw":{}}],"props":{},"body":[]}]},"hud":{"params":[{"n":"day","d":{"k":"const","v":1},"kind":"p"}],"props":{"tag":{"k":"name","id":"hud"},"zorder":{"k":"const","v":5},"style_prefix":{"k":"const","v":"hud"}},"defaults":[["open",{"k":"const","v":false}]],"body":[{"w":"frame","args":[],"props":{"xalign":{"k":"const","v":0.0},"yalign":{"k":"const","v":0.0}},"body":[{"w":"vbox","args":[],"props":{"spacing":{"k":"const","v":6}},"body":[{"w":"text","args":[{"k":"const","v":"Day [day]"}],"props":{},"body":[]},{"c":"if","branches":[{"c":{"k":"cmp","l":{"k":"name","id":"money"},"ops":[">"],"r":[{"k":"const","v":10}]},"body":[{"w":"text","args":[{"k":"const","v":"Rich"}],"props":{},"body":[]}]},{"c":null,"body":[{"w":"text","args":[{"k":"const","v":"Poor"}],"props":{},"body":[]}]}]},{"c":"for","t":{"k":"name","id":"item"},"it":{"k":"name","id":"inventory"},"body":[{"w":"textbutton","args":[{"k":"name","id":"item"}],"props":{"action":{"k":"call","f":{"k":"name","id":"SetVariable"},"a":[{"k":"const","v":"held"},{"k":"name","id":"item"}],"kw":{}}},"body":[]}]},{"w":"textbutton","args":[{"k":"const","v":"Menu"}],"props":{"action":{"k":"call","f":{"k":"name","id":"ToggleScreenVariable"},"a":[{"k":"const","v":"open"}],"kw":{}},"selected":{"k":"name","id":"open"}},"body":[]},{"c":"if","branches":[{"c":{"k":"name","id":"open"},"body":[{"w":"text","args":[{"k":"const","v":"open!"}],"props":{},"body":[]}]}]},{"w":"textbutton","args":[{"k":"const","v":"Go"}],"props":{"action":{"k":"list","v":[{"k":"call","f":{"k":"name","id":"SetVariable"},"a":[{"k":"const","v":"went"},{"k":"const","v":true}],"kw":{}},{"k":"call","f":{"k":"name","id":"Jump"},"a":[{"k":"const","v":"elsewhere"}],"kw":{}}]}},"body":[]}]}]},{"c":"key","key":{"k":"const","v":"K_h"},"action":{"k":"call","f":{"k":"name","id":"SetVariable"},"a":[{"k":"const","v":"hotkey"},{"k":"const","v":true}],"kw":{}}},{"c":"timer","delay":{"k":"const","v":0.02},"action":{"k":"call","f":{"k":"name","id":"SetVariable"},"a":[{"k":"const","v":"ticked"},{"k":"const","v":true}],"kw":{}},"repeat":null}]},"mood":{"params":[],"props":{},"defaults":[],"body":[{"w":"imagebutton","args":[],"props":{"action":{"k":"tuple","v":[{"k":"call","f":{"k":"name","id":Line truncated
+94
View File
@@ -0,0 +1,94 @@
# Source of screens.json. Regenerate from backend/ with:
# python -m tests.make_screens ../frontend/tests/fixtures/screens.rpy ../frontend/tests/fixtures/screens.json
style hud_text:
size 30
color "#abc"
style hud_button:
background "#123"
hover_background "#456"
padding (10, 4)
transform fade_in:
alpha 0.0
linear 0.1 alpha 1.0
screen hud(day=1):
tag hud
zorder 5
style_prefix "hud"
default open = False
frame:
xalign 0.0
yalign 0.0
vbox:
spacing 6
text "Day [day]"
if money > 10:
text "Rich"
else:
text "Poor"
for item in inventory:
textbutton item action SetVariable("held", item)
textbutton "Menu" action ToggleScreenVariable("open") selected open
if open:
text "open!"
textbutton "Go" action [SetVariable("went", True), Jump("elsewhere")]
key "K_h" action SetVariable("hotkey", True)
timer 0.02 action SetVariable("ticked", True)
screen shop(item="none"):
modal True
imagebutton auto "buy_%s.png" action Return(item) at fade_in
textbutton "Cancel" action Return(None) sensitive can_cancel
screen counter():
add "#fff" xysize (10, 10)
bar value VariableValue("score", 10) xsize 100
transform blink(delay, cycle=1.0):
alpha 0.0
pause delay
linear cycle alpha 1.0
screen card(title):
frame:
has vbox
text title
transclude
screen deck():
use card("Deck"):
text "child " + "one"
textbutton "Pick" action Return(1)
add "pic.png" crop(1, 2, 30, 40)
add "dot.png" at blink(0.02, 0.02)
add "bg.png":
alpha 0.0
linear 0.1 alpha 1.0
on "show" action SetVariable("shown", True)
on "hide" action SetVariable("hid", True)
text "x" pos(5, 6)
vpgrid:
cols 2
text "a"
text "b"
textbutton "Language" action [Language("ru"), ToggleScreen("counter")]
add Crop((0, 0, 8, 8), "sprite.png")
screen mood():
imagebutton:
if money > 10:
idle "rich.png"
else:
idle "poor.png"
action SetVariable("mood_clicked", 1), SetVariable("mood_done", True)
label start:
show screen mood
show screen deck
show screen hud(2)
call screen shop("sword")
show screen counter
return
+227
View File
@@ -0,0 +1,227 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { parseHTML } from "linkedom";
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, HTMLInputElement: window.HTMLInputElement,
HTMLImageElement: window.HTMLImageElement, Image: window.Image });
window.setTimeout = setTimeout;
window.clearTimeout = clearTimeout;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 1);
const compiled = await build({
stdin: { contents: `export * from "./python"; export * from "./screens";`,
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
});
const { Python, ScreenManager, userBuiltins } =
await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const fixture = JSON.parse(readFileSync(new URL("./fixtures/screens.json", import.meta.url), "utf8"));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
function setup(extra = {}) {
const store = { money: 20, inventory: ["sword", "shield"], can_cancel: true, ...extra };
const builtins = {};
const py = new Python({
get: (n) => (n in store ? store[n] : builtins[n]),
has: (n) => n in store || n in builtins,
set: (n, v) => { store[n] = v; },
del: (n) => { delete store[n]; },
});
const calls = { jump: [], play: [], notify: [], language: [] };
const host = {
py, baseSize: 24,
fileUrl: (p) => `/f/${p}`,
image: (value) => { const e = document.createElement("img"); e.setAttribute("src", `/f/${value}`); return e; },
text: (el, value) => { el.textContent = value.replace(/\[(\w+)\]/g, (_m, n) => String(py.lookup(n))); },
fontFamily: () => null,
play: (...a) => calls.play.push(a), stop() {}, notify: (m) => calls.notify.push(m),
jump: (l) => calls.jump.push(l), call() {}, quit() {}, mainMenu() {},
openMenu() {}, openUrl() {}, setLanguage: (c) => calls.language.push(c),
transform: (n) => fixture.transforms[n] ?? null,
atl: (n) => fixture.atl[n] ?? null,
};
const manager = new ScreenManager(host, fixture.screens, fixture.styles);
Object.assign(builtins, userBuiltins(py), manager.builtins());
document.body.append(manager.layer);
return { manager, store, calls, py };
}
const texts = (root) => [...root.querySelectorAll(".pg-stext")].map((e) => e.textContent);
const buttons = (root) => [...root.querySelectorAll(".pg-sbtn")];
const click = (el) => el.onclick({ stopPropagation() {}, preventDefault() {} });
const settle = () => wait(15);
test("a screen renders its widgets, conditions, loops and interpolated text", () => {
const { manager } = setup();
manager.show("hud", [2]);
const root = manager.layer;
assert.deepEqual(texts(root).slice(0, 2), ["Day 2", "Rich"]);
assert.deepEqual(buttons(root).map((b) => b.textContent), ["sword", "shield", "Menu", "Go"]);
const frame = root.querySelector(".pg-sframe");
assert.ok(frame, "frame rendered");
const label = root.querySelector(".pg-sbtn .pg-stext");
assert.equal(label.style.color, "#abc", "style hud_text applies through style_prefix");
assert.equal(label.style.fontSize, "30px");
assert.equal(root.querySelector(".pg-sbtn").style.background, "#123", "style hud_button applies");
});
test("conditions follow the game state when the screen refreshes", async () => {
const { manager, store } = setup();
manager.show("hud");
assert.ok(texts(manager.layer).includes("Rich"));
store.money = 1;
manager.refresh();
await settle();
assert.ok(texts(manager.layer).includes("Poor"));
});
test("actions change variables, screen variables and the selected state", async () => {
const { manager, store } = setup();
manager.show("hud");
click(buttons(manager.layer)[1]);
assert.equal(store.held, "shield");
assert.ok(!texts(manager.layer).includes("open!"));
click(buttons(manager.layer)[2]);
await settle();
assert.ok(texts(manager.layer).includes("open!"), "ToggleScreenVariable re-rendered the screen");
assert.ok(buttons(manager.layer)[2].classList.contains("pg-sselected"), "`selected open` marks the button");
});
test("Jump stops the remaining actions and goes to the label", () => {
const { manager, store, calls } = setup();
manager.show("hud");
click(buttons(manager.layer)[3]);
assert.equal(store.went, true);
assert.deepEqual(calls.jump, ["elsewhere"]);
});
test("call screen resolves with the Return value; a modal screen blocks what is below", async () => {
const { manager } = setup();
let result = "pending";
manager.show("shop", ["sword"], {}, { call: true, onReturn: (v) => { result = v; } });
const root = manager.layer;
assert.ok(root.querySelector(".pg-sinst").classList.contains("pg-smodal"));
const image = root.querySelector(".pg-simgbtn img");
assert.equal(image.getAttribute("src"), "/f/buy_idle.png", "auto \"buy_%s.png\" picks the idle image");
click(root.querySelector(".pg-simgbtn"));
assert.equal(result, "sword");
manager.hideCall("shop");
assert.equal(root.querySelector(".pg-sinst"), null);
});
test("insensitive buttons do nothing", () => {
const { manager } = setup({ can_cancel: false });
let result = "pending";
manager.show("shop", [], {}, { call: true, onReturn: (v) => { result = v; } });
const cancel = buttons(manager.layer)[1];
assert.ok(cancel.classList.contains("pg-sdisabled"));
click(cancel);
assert.equal(result, "pending");
});
test("timers fire, keys work, and hiding stops both", async () => {
const { manager, store } = setup();
manager.show("hud");
await wait(40);
assert.equal(store.ticked, true);
const press = () => {
const event = new window.Event("keydown");
event.key = "h";
window.dispatchEvent(event);
};
press();
assert.equal(store.hotkey, true);
store.hotkey = false;
manager.hide("hud");
press();
assert.equal(store.hotkey, false);
});
test("screens can be saved and restored with their arguments", () => {
const { manager } = setup();
manager.show("hud", [7]);
const saved = JSON.parse(JSON.stringify(manager.snapshot()));
manager.clear();
assert.equal(manager.layer.querySelector(".pg-sinst"), null);
manager.restore(saved);
assert.ok(texts(manager.layer).includes("Day 7"));
});
test("bars show a variable and unknown screens fail loudly", () => {
const { manager, store } = setup({ score: 5 });
manager.show("counter");
assert.equal(manager.layer.querySelector(".pg-sbar-fill").style.width, "50%");
store.score = 10;
assert.throws(() => manager.show("missing"), /missing/);
});
test("use with children transcludes them in the caller's scope; `has` nests the rest of the block", () => {
const { manager } = setup();
manager.show("deck");
const card = manager.layer.querySelector(".pg-sframe");
assert.ok(card.querySelector(".pg-svbox"), "`has vbox` created the box");
assert.deepEqual([...card.querySelectorAll(".pg-stext")].map((e) => e.textContent), ["Deck", "child one", "Pick"]);
assert.equal(card.querySelector(".pg-sbtn").textContent, "Pick");
});
test("on show runs once, on hide when the screen goes away", async () => {
const { manager, store } = setup();
manager.show("deck");
await settle();
assert.equal(store.shown, true);
store.shown = false;
manager.refresh();
await settle();
assert.equal(store.shown, false, "re-rendering does not repeat `on show`");
manager.hide("deck");
assert.equal(store.hid, true);
});
test("crop, glued properties, viewport grids, displayable values and extra actions", () => {
const { manager, calls } = setup();
manager.show("deck");
const root = manager.layer;
const pic = [...root.querySelectorAll(".pg-sadd")].find((e) => e.style.width === "30px");
assert.equal(pic.style.height, "40px", "crop (1, 2, 30, 40) shows a 30x40 window");
assert.equal(pic.firstChild.style.left, "-1px");
const small = [...root.querySelectorAll(".pg-sadd")].find((e) => e.style.width === "8px");
assert.ok(small, "Crop((0, 0, 8, 8), ...) works as a value");
const grid = root.querySelector(".pg-svpgrid");
assert.equal(grid.style.gridTemplateColumns, "repeat(2, auto)");
const x = [...root.querySelectorAll(".pg-stext")].find((e) => e.textContent === "x");
assert.equal(x.style.left, "calc(5px + 0px)", "pos(5, 6) without a space is understood");
const button = [...root.querySelectorAll(".pg-sbtn")].find((b) => b.textContent === "Language");
click(button);
assert.deepEqual(calls.language, ["ru"]);
assert.ok(manager.has("counter"));
});
test("animated transforms with parameters start from their arguments and do not replay", async () => {
const { manager } = setup();
manager.show("deck");
const dot = () => [...manager.layer.querySelectorAll(".pg-sadd")].find((e) => e.getAttribute("src") === "/f/dot.png");
assert.equal(dot().style.opacity, "0");
await wait(120);
assert.equal(dot().style.opacity, "1");
manager.refresh();
await settle();
assert.equal(dot().style.opacity, "1", "a finished animation stays finished after a refresh");
});
test("an `if` that only sets properties picks the image; decompiled comma actions run all parts", async () => {
const { manager, store } = setup();
manager.show("mood");
const picture = () => manager.layer.querySelector(".pg-simgbtn img").getAttribute("src");
assert.equal(picture(), "/f/rich.png");
store.money = 1;
manager.refresh();
await settle();
assert.equal(picture(), "/f/poor.png");
click(manager.layer.querySelector(".pg-simgbtn"));
assert.equal(store.mood_clicked, 1);
assert.equal(store.mood_done, true);
});