feat: Ren'Py screens (screen/call screen/show screen) with actions and styles
Converter - screens.py parses the screen language: containers (fixed, frame, vbox, hbox, grid, vpgrid, viewport, button, has), widgets (text, textbutton, imagebutton, add, bar ...), if/for/use/transclude, default, python, key, timer, on, inline ATL, conditional properties, and decompiled forms (unparenthesised operators, glued property values, comma separated actions) - style statements, call/show/hide screen operators and Python-level renpy.show_screen are translated; only screens the game shows are audited, unused stock screens no longer block an import; undefined screens do - ATL values may be computed at run time and transforms take parameters (at blink(0.2, 0.3)); blur property Player - screens.ts builds the DOM from the IR and rebuilds it on state changes; buttons with idle/hover/selected/insensitive states, tooltips, sounds, modal screens, timers and keys that survive re-rendering, saved with the save file - Actions: Jump, Call, Return, SetVariable, Toggle*, SetScreenVariable, Show, Hide, ToggleScreen, Function, Play, Notify, If, Language, ShowMenu, OpenURL ... - Crop/LiveCrop/Solid/Frame/Transform/DynamicDisplayable values, crop property, globals(), renpy.variant/change_language/seen_image, transition stubs Tests: screen parser and runtime (linkedom DOM), screens fixture compiled from tests/fixtures/screens.rpy. Docs: docs/SCREENS.md. Twisted-Thoughts now passes the strict compatibility check. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
47fc121e7d
commit
62ece25902
24 files changed
+2996
-59
No files matched your search
@@ -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.*` пока не играбельны.
|
||||||
пока не играбельны.
|
|
||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
name = c.word()
|
||||||
|
if not name:
|
||||||
return []
|
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(""))}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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]))
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"] == {}
|
||||||
@@ -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` (сейчас проверка справочная).
|
||||||
@@ -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 на кнопках.
|
||||||
Generated
+302
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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
@@ -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> }[];
|
||||||
}
|
}
|
||||||
Vendored
+1
@@ -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
|
||||||
Vendored
+94
@@ -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
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user