diff --git a/README.md b/README.md index 841353e..635bfee 100644 --- a/README.md +++ b/README.md @@ -125,10 +125,12 @@ Python игры выполняется в песочнице плеера: св (`linear`/`ease`/`easein`/`easeout`), `pause`, `repeat`, `parallel`, `choice`, `block`, `on show`/`on hide`, вызов других transform. -Пока **не** поддерживается: `screen`-язык (кастомные экраны, карты, -мини-игры), в ATL — `function`, `time`, `contains`, `matrixcolor` и шейдеры, +Экраны игры (`screen`, `call screen`, `show screen`) и их действия — см. +[docs/SCREENS.md](docs/SCREENS.md). + +Пока **не** поддерживается: в экранах — `input`, `imagemap`, `mousearea`, `drag`, `side`, в ATL — +`function`, `time`, `contains`, `matrixcolor` и шейдеры, в Python — специальные методы кроме `__init__`/`__str__`/`__repr__`, генераторы, `with`, системные модули (`os`, `socket`, …) и картинки, которые игра вычисляет своими функциями. Такие места перечислены в отчёте конвертации у версии и -останавливают импорт. Игры-«песочницы», где навигация построена на экранах, -пока не играбельны. +останавливают импорт. Игры-«песочницы» на `renpy.ui.*` и `im.*` пока не играбельны. diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py index d30d93d..be225b7 100644 --- a/backend/app/converter/atl.py +++ b/backend/app/converter/atl.py @@ -11,6 +11,9 @@ A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps: * ``{"op": "on", "events": [...], "block": block}`` * ``{"op": "use", "name": transform}`` +A number or property value that is not a literal becomes ``{"e": expr}`` and is evaluated when the +timeline runs, with the transform's parameters in scope (``transform blink(delay, cycle):``). + Everything else (``function``, ``contains``, ``time``, expressions, shaders, matrix colours) is reported through ``warn`` so the game is not imported half-working. """ @@ -19,6 +22,7 @@ import re from collections.abc import Callable from typing import Any +from app.converter import pyexpr from app.converter.rpy_helpers import POSITION_PROPS from app.converter.rpy_lexer import Line @@ -69,12 +73,24 @@ class AtlParser: # --- helpers ------------------------------------------------------------------- - def number(self, raw: str, line: Line) -> float | None: + def value(self, raw: str, line: Line) -> Any: + """A literal, or ``{"e": expr}`` for anything computed at run time.""" try: - value = ast.literal_eval(raw) + return ast.literal_eval(raw) except (ValueError, SyntaxError): + pass + try: + return {"e": pyexpr.parse_expr(raw)} + except pyexpr.Unsupported: self.warn(f"Не поддерживается ATL: выражение {raw}", line) return None + + def number(self, raw: str, line: Line) -> Any: + value = self.value(raw, line) + if value is None: + return None + if isinstance(value, dict): + return value if isinstance(value, bool) or not isinstance(value, (int, float)): self.warn(f"Не поддерживается ATL: значение {raw}", line) return None @@ -94,15 +110,10 @@ class AtlParser: if i + 1 >= len(parts): self.warn(f"Не поддерживается ATL: у {key} нет значения", line) return out - raw = parts[i + 1] - try: - value = ast.literal_eval(raw) - except (ValueError, SyntaxError): - self.warn(f"Не поддерживается ATL: выражение {raw}", line) + value = self.value(parts[i + 1], line) + if value is None: return out - if isinstance(value, tuple): - value = list(value) - out[key] = value + out[key] = list(value) if isinstance(value, tuple) else value i += 2 return out @@ -123,7 +134,7 @@ class AtlParser: if first == "repeat": repeat = True if len(parts) == 1 else self.number(parts[1], line) if repeat is not True and repeat is not None: - repeat = int(repeat) + repeat = repeat if isinstance(repeat, dict) else int(repeat) elif first == "pause": seconds = self.number(parts[1], line) if len(parts) > 1 else None if seconds is not None: @@ -182,7 +193,7 @@ class AtlParser: words = tokens(current.text.rstrip(":").strip()) if not words or words[0] != first: break - weight = 1.0 + weight: Any = 1.0 if first == "choice" and len(words) > 1: weight = self.number(words[1], current) or 1.0 group.append((weight, self.block(current.block))) @@ -194,10 +205,18 @@ class AtlParser: return j +def computed(value: Any) -> bool: + if isinstance(value, dict): + return "e" in value + return isinstance(value, list) and any(computed(item) for item in value) + + def static_props(block: Block) -> dict[str, Any] | None: """Merged properties when the block only assigns, else None (it needs the timeline).""" if block["repeat"] is not None or any(step["op"] != "set" for step in block["steps"]): return None + if any(computed(v) for step in block["steps"] for v in step["props"].values()): + return None merged: dict[str, Any] = {} for step in block["steps"]: merged.update(step["props"]) @@ -210,5 +229,5 @@ def initial_props(block: Block) -> dict[str, Any]: for step in block["steps"]: if step["op"] != "set": break - merged.update(step["props"]) + merged.update({k: v for k, v in step["props"].items() if not computed(v)}) return merged diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 66f7306..4f832c9 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -105,6 +105,16 @@ CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound") for method in ("set_volume", "set_pause", "set_pan")} CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition"} 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()) @@ -139,6 +149,11 @@ def _bound_names(root) -> tuple[set[str], set[str]]: target(value.get("t")) elif kind == "lambda": 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": target(value.get("t")) for gen in value.get("gens", []): @@ -152,9 +167,42 @@ def _bound_names(root) -> tuple[set[str], set[str]]: 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: """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} def dotted(value): @@ -189,5 +237,9 @@ def audit_ir(data) -> None: for path, module in data.modules.items(): visit(module, path) 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.images, "image") diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index 19e0d0d..e91b2b1 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -191,6 +191,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress "images": images, "transforms": data.transforms, "atl": data.atl, + "screens": {name: data.screens[name] for name in sorted(_used_screens(data))}, + "styles": data.styles, "transitions": data.transitions, "audio": audio, "channels": data.channels, @@ -297,6 +299,20 @@ def _size(path: Path) -> int: 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: parts = [x.lower() for x in p.relative_to(game_dir).parts] return len(parts) > 1 and parts[0] in ENGINE_DIRS diff --git a/backend/app/converter/rpy_helpers.py b/backend/app/converter/rpy_helpers.py index 72943ec..f22c606 100644 --- a/backend/app/converter/rpy_helpers.py +++ b/backend/app/converter/rpy_helpers.py @@ -11,7 +11,7 @@ Node = dict[str, Any] POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter", "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"} diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index 98f7d07..60e8729 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -1,6 +1,7 @@ """Ren'Py script (.rpy) → PGVN IR.""" import ast +import re from collections import Counter from dataclasses import dataclass, field from typing import Any @@ -27,6 +28,7 @@ from app.converter.rpy_helpers import ( _static_props, ) from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree +from app.converter.screens import ScreenParser, referenced_screens, tokens Node = dict[str, Any] @@ -59,6 +61,8 @@ TRANSITION_CLASSES = {"Dissolve", "Fade", "ImageDissolve", "AlphaDissolve", "Pix POSITIONS = {"left", "right", "center", "truecenter", "topleft", "topright", "top", "offscreenleft", "offscreenright", "default", "reset"} 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"} 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_uses: dict[str, set[str]] = field(default_factory=dict) 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) audio: dict[str, str] = field(default_factory=dict) defaults: list[list[Any]] = field(default_factory=list) @@ -88,6 +97,17 @@ class ScriptData: warning_examples: dict[str, str] = field(default_factory=dict) 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: self.warnings[kind] += 1 if blocking: @@ -680,7 +700,7 @@ class RpyParser: # e.g. get_face_path("base"): an image computed by game code e = self.expr(child.text, child) if e is not None: - self.d.warn("Изображение задано выражением", self.where(child)) + self.d.warn("Изображение задано выражением", self.where(child), blocking=False) frames.append({"img": {"expr": e}}) continue if not frames or not frames[-1].get("d"): @@ -814,6 +834,10 @@ class RpyParser: issues: list[tuple[str, str]] = [] uses: set[str] = set() 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 if animation: self.d.atl[name] = animation @@ -823,6 +847,16 @@ class RpyParser: self.use_transform(name) 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: """A transform is used by the game: its unsupported constructs now block the import.""" self.d.atl_referenced.add(name) @@ -932,12 +966,17 @@ class RpyParser: args = [_const(a) for a in e["a"]] return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5, "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) - spec = {"props": self.d.transforms[fname], "name": fname} + spec = {"props": self.d.transforms.get(fname, {}), "name": fname} if fname in self.d.atl: spec["atl"] = fname + spec["args"], spec["kw"] = e["a"], e["kw"] 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": self.use_transform(e["id"]) if e and e["k"] == "name" and e["id"] not in self.d.transforms: @@ -962,6 +1001,61 @@ class RpyParser: ], "repeat": None} 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]: c = self.cursor(line) c.keyword("scene") @@ -978,14 +1072,14 @@ class RpyParser: c = self.cursor(line) c.keyword("show") if c.keyword("screen"): - self.d.warn("Не поддерживается: screen", self.where(line)) - return [] + node = self.screen_call(c, line) + return [{"op": "show_screen", **node}] if node else [] if c.keyword("text"): text = c.string() if text is not None: node: Node = {"op": "show", "img": {"text": text}} 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(""))}} self.show_clauses(c, line, node) return [node] @@ -998,7 +1092,11 @@ class RpyParser: c = self.cursor(line) c.keyword("hide") if c.keyword("screen"): - return [] + name = c.word() + if not name: + return [] + self.d.screen_used.add(name) + return [{"op": "hide_screen", "name": name}] spec = self.image_spec(c, line) node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]} self.show_clauses(c, line, node) @@ -1113,8 +1211,8 @@ class RpyParser: c = self.cursor(line) c.keyword("call") if c.keyword("screen"): - self.d.warn("Не поддерживается: call screen", self.where(line)) - return [] + node = self.screen_call(c, line) + return [{"op": "call_screen", **node}] if node else [] if c.keyword("expression"): node: Node = {"op": "call", "expr": self.expr(c.until({"pass", "from"}), line, const(""))} diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py new file mode 100644 index 0000000..5da1d52 --- /dev/null +++ b/backend/app/converter/screens.py @@ -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 diff --git a/backend/app/mods.py b/backend/app/mods.py index 6601796..c6e012c 100644 --- a/backend/app/mods.py +++ b/backend/app/mods.py @@ -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 |= {"nani_add_choice", "nani_menu", "nani_clear_choice", "nani_hide_characters", "nani_exit"} OPS.add("nani_command") +OPS |= {"call_screen", "show_screen", "hide_screen"} MISSING = object() @@ -77,6 +78,13 @@ def validate(doc, manifest): for key in ("expr", "c", "d"): if isinstance(n.get(key), dict): 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 not isinstance(n.get("what"), str) or not isinstance(n.get("tid"), str) or n["tid"] in tids: raise ValueError("Некорректная реплика или повторяющийся tid") diff --git a/backend/tests/make_screens.py b/backend/tests/make_screens.py new file mode 100644 index 0000000..59d2fd2 --- /dev/null +++ b/backend/tests/make_screens.py @@ -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])) diff --git a/backend/tests/test_compatibility.py b/backend/tests/test_compatibility.py index 604c446..a0c1393 100644 --- a/backend/tests/test_compatibility.py +++ b/backend/tests/test_compatibility.py @@ -73,13 +73,15 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path): write(tmp_path / "game/script.rpy", source) assert inspect_directory(tmp_path)["compatible"] 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) @pytest.mark.parametrize("source,reason", [ ('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"), ]) def test_unsupported_renpy_logic_never_degrades_to_ready(source, reason): diff --git a/backend/tests/test_screens.py b/backend/tests/test_screens.py new file mode 100644 index 0000000..a8dd838 --- /dev/null +++ b/backend/tests/test_screens.py @@ -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"] == {} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 256b8d8..e27481e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -145,8 +145,8 @@ LOCKED`), Redis не нужен. Планы: -* поддержка `screen`-языка и ATL `function`/`matrixcolor`/шейдеров (базовый ATL - с движением уже есть: `backend/app/converter/atl.py`, `frontend/src/player/atl.ts`); +* ATL `function`, `matrixcolor`, шейдеры и `im.*` (экраны и ATL с движением уже есть: + [SCREENS.md](SCREENS.md), `backend/app/converter/atl.py`, `frontend/src/player/atl.ts`); * другие движки (TyranoBuilder, RPG Maker MV) через тот же IR; * дальнейшее дробление `engine.ts` на модули (рендер сцены, UI, сохранения); * типизация backend под `mypy` (сейчас проверка справочная). diff --git a/docs/SCREENS.md b/docs/SCREENS.md new file mode 100644 index 0000000..c2cedd0 --- /dev/null +++ b/docs/SCREENS.md @@ -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 на кнопках. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8dd317c..0ead3a3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,10 +16,18 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.3.4", + "linkedom": "0.18.14", "typescript": "5.6.3", "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": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1232,6 +1240,20 @@ "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": { "version": "4.29.4", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", @@ -1294,6 +1316,41 @@ "dev": true, "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": { "version": "3.2.3", "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": { "version": "1.5.464", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", @@ -1326,6 +1454,19 @@ "dev": true, "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": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", @@ -1400,6 +1541,118 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1432,6 +1685,31 @@ "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": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1490,6 +1768,23 @@ "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": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1682,6 +1977,13 @@ "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": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", diff --git a/frontend/package.json b/frontend/package.json index 244826c..27f8ea9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,6 +18,7 @@ "@types/react": "18.3.12", "@types/react-dom": "18.3.1", "@vitejs/plugin-react": "4.3.4", + "linkedom": "0.18.14", "typescript": "5.6.3", "vite": "5.4.11" } diff --git a/frontend/src/player/atl.ts b/frontend/src/player/atl.ts index 1a7b1c6..270ed9b 100644 --- a/frontend/src/player/atl.ts +++ b/frontend/src/player/atl.ts @@ -2,17 +2,22 @@ export type AtlProps = Record; +/** A literal, or an expression evaluated when the timeline runs (`{ e: expr }`). */ +export type AtlValue = T | { e: any }; + export interface AtlBlock { steps: AtlStep[]; - repeat: null | true | number; + repeat: null | true | AtlValue; + /** Parameters of `transform name(a, b=1):`. */ + params?: { n: string; d: any; kind: string }[]; } export type AtlStep = | { op: "set"; props: AtlProps } - | { op: "move"; warp: "linear" | "ease" | "easein" | "easeout"; t: number; props: AtlProps } - | { op: "pause"; t: number } + | { op: "move"; warp: "linear" | "ease" | "easein" | "easeout"; t: AtlValue; props: AtlProps } + | { op: "pause"; t: AtlValue } | { op: "parallel"; blocks: AtlBlock[] } - | { op: "choice"; blocks: { w: number; block: AtlBlock }[] } + | { op: "choice"; blocks: { w: AtlValue; block: AtlBlock }[] } | { op: "block"; block: AtlBlock } | { op: "on"; events: string[]; block: AtlBlock } | { op: "use"; name: string }; @@ -24,6 +29,20 @@ export interface AtlHost { props(name: string): AtlProps | null; /** Called after every change of the working properties. */ apply(props: AtlProps): void; + /** Evaluate a run-time value with the transform's parameters in scope. */ + eval?(expr: any, vars: Map): 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 }, + ev: (expr: any) => any): Map { + const vars = new Map(); + (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"]); @@ -50,15 +69,34 @@ function nextFrame() { export class AtlRunner { private token = 0; - constructor(private host: AtlHost, private props: AtlProps) {} + constructor(private host: AtlHost, private props: AtlProps, private vars: Map = new Map()) {} - /** Start the timeline; resolves when it ends (never, for endless loops) or is stopped. */ - async start(block: AtlBlock): Promise { + /** Resolve `{ e: expr }` values (also inside tuples). */ + private val(v: AtlValue | 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 | 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 { const token = ++this.token; try { await this.run(block, token, 0, "show"); + return true; } catch (err) { if (!(err instanceof Stopped)) throw err; + return false; } } @@ -126,7 +164,7 @@ export class AtlRunner { } loops++; if (block.repeat === null) return; - if (block.repeat !== true && loops >= block.repeat) return; + if (block.repeat !== true && loops >= this.seconds(block.repeat as AtlValue)) return; // a loop without any waiting step would freeze the page: yield one frame per turn 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 { switch (step.op) { case "set": - Object.assign(this.props, step.props); + Object.assign(this.props, this.props_(step.props)); this.apply(); return false; - case "pause": - await this.sleep(step.t, token); - return step.t > 0; - case "move": - await this.move(step, token); - return step.t > 0; + case "pause": { + const t = this.seconds(step.t); + await this.sleep(t, token); + return t > 0; + } + case "move": { + const t = this.seconds(step.t); + await this.move(step, t, token); + return t > 0; + } case "parallel": await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event))); return true; 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; - 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); return true; } @@ -199,22 +242,23 @@ export class AtlRunner { } } - private async move(step: Extract, token: number) { + private async move(step: Extract, seconds: number, token: number) { + const target = this.props_(step.props); 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 start = performance.now(); - const duration = step.t * 1000; + const duration = seconds * 1000; while (duration > 0) { await nextFrame(); this.alive(token); const raw = Math.min(1, (performance.now() - start) / duration); 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(); if (raw >= 1) return; } - Object.assign(this.props, step.props); + Object.assign(this.props, target); this.apply(); } } diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index e0bf2aa..bf92886 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -6,6 +6,7 @@ import { Renderer, type ResolvedImage } from "./renderer"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { languageName, rememberLanguage, type Translator } from "./i18n"; 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 type { CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, @@ -35,6 +36,9 @@ export class Engine { private stack: Frame[] = []; private gen = 0; private waiters: (() => void)[] = []; + private screens: ScreenManager; + private screenFonts = new Map(); + private seenImages = new Set(); private running = false; private skip = false; @@ -101,7 +105,8 @@ export class Engine { del: (n) => void delete this.store[n], }; 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; 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)), transform: (name) => game.transforms[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)))), }); this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {}); @@ -125,6 +139,7 @@ export class Engine { } destroy() { + this.screens.destroy(); this.gen++; this.wakeAll(); this.audio.stopAll(); @@ -303,8 +318,12 @@ export class Engine { throw new JumpSignal("jump", "__quit__"); }, version: () => "pgvn", + ...this.screens.renpyApi(), // presentation-only: nothing to roll back or replay, and the next transition is chosen by `with` 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, transition: () => null, }; @@ -330,6 +349,10 @@ export class Engine { random, 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), + // 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), int: (x: any) => Math.trunc(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() { + this.screens.clear(); this.store = {}; try { this.py.exec(this.game.init); @@ -557,6 +645,7 @@ export class Engine { } try { await this.exec(node); + this.screens.refresh(); } catch (err) { if (err instanceof JumpSignal) { 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) { switch (node.op) { case "nani_exit": @@ -688,6 +784,26 @@ export class Engine { return this.jump(node.target ?? pyStr(this.safeEval(node.expr))); case "call": 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((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": this.renderer.camera(node.at ?? []); this.advance(); @@ -727,6 +843,7 @@ export class Engine { return; } 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; this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind }); } @@ -1003,6 +1120,7 @@ export class Engine { this.wakeAll(); this.onAdvance = null; this.skipTick = null; + this.screens.hideCalls(); this.closeScreen(); this.menuEl.classList.add("pg-hidden"); this.centeredEl.classList.add("pg-hidden"); @@ -1097,6 +1215,7 @@ export class Engine { cur: anchor(this.cur), stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })), store: encodeValue(this.store), + screens: encodeValue(this.screens.snapshot()), scene: this.renderer.snapshot(), music: this.audio.current("music"), window: this.windowShown, @@ -1142,6 +1261,7 @@ export class Engine { this.restart(() => { this.resetStore(); Object.assign(this.store, decodeValue(data.store)); + this.screens.restore(decodeValue(data.screens ?? [])); this.renderer.scene(); for (const s of data.scene) { const img = this.resolveImage(s.name); @@ -1239,7 +1359,7 @@ export class Engine { this.centeredEl = el("div", "pg-centered pg-hidden"); this.screen = el("div", "pg-screen 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 const hud = el("button", "pg-hud-btn", "☰"); @@ -1255,7 +1375,7 @@ export class Engine { stage.addEventListener("click", (ev) => { 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(); }); stage.addEventListener("wheel", (ev) => { diff --git a/frontend/src/player/player.css b/frontend/src/player/player.css index 8f674f1..ffe33c5 100644 --- a/frontend/src/player/player.css +++ b/frontend/src/player/player.css @@ -343,3 +343,20 @@ .pg-dialog-row { display: flex; justify-content: flex-end; gap: 10px; } .pg-dialog button { font-size: 15px; padding: 9px 18px; } .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; } diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index fea5cde..fb0b0dd 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -1,7 +1,7 @@ // 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. -import { AtlRunner, type AtlBlock } from "./atl"; +import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; import type { AtSpec, ImageDef, ShownImage, Transition } from "./types"; const POSITIONS: Record> = { @@ -29,6 +29,8 @@ export interface RendererHost { evalCond(e: any): boolean; transform(name: string): Record | null; atl(name: string): AtlBlock | null; + /** Evaluate an expression, optionally with extra variables (ATL parameters) in scope. */ + evalExpr(expr: any, frame?: Map): any; text(e: any): string; } @@ -105,7 +107,8 @@ export class Renderer { block: (name) => this.host.atl(name), props: (name) => this.host.transform(name), 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 }); void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err)); } @@ -207,6 +210,11 @@ export class Renderer { 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. */ private animated(def: ImageDef, depth: number): HTMLElement { const box = document.createElement("div"); @@ -311,7 +319,10 @@ export class Renderer { if (ax.endsWith("px") || ay.endsWith("px")) 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.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 --------------------------------------------------------------- diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts new file mode 100644 index 0000000..3cfa0fa --- /dev/null +++ b/frontend/src/player/screens.ts @@ -0,0 +1,1218 @@ +// Ren'Py screens (see backend/app/converter/screens.py): built as DOM from the IR, re-built +// whenever game state may have changed. Expressions run in the same Python sandbox as the script. + +import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; +import { PyDict, pyStr, truthy, type Python } from "./python"; +import type { AtSpec, Expr } from "./types"; + +export interface ScreenDef { + params: { n: string; d: Expr | null; kind: string }[]; + props: Record; + defaults: [string, Expr][]; + body: any[]; +} + +export interface StyleDef { + is: string | null; + props: Record; +} + +export interface ScreenHost { + py: Python; + fileUrl(path: string): string | null; + /** Element for an image name, file path or colour. */ + image(value: string): HTMLElement | null; + /** Fill `el` with rich text (markup, interpolation, translation). */ + text(el: HTMLElement, value: string, size: number): void; + fontFamily(file: string): string | null; + baseSize: number; + play(channel: string, file: string, loop: boolean): void; + stop(channel: string): void; + notify(message: string): void; + jump(label: string): void; + call(label: string): void; + quit(): void; + mainMenu(): void; + openMenu(): void; + openUrl(url: string): void; + setLanguage(code: string | null): void; + transform(name: string): Record | null; + atl(name: string): AtlBlock | null; +} + +type Props = Record; + +class ActionAbort extends Error {} + +/** A screen action such as Jump("x"); run when the player activates a widget. */ +export class ScreenAction { + constructor(public kind: string, public args: any[], public kw: Props, public screen: Instance | null) {} +} + +/** A displayable built by game code: Crop(...), Solid(...), Frame(...), Transform(...). */ +export class Displayable { + constructor(public kind: string, public args: any[], public kw: Props) {} +} + +export class BarValue { + constructor(public value: () => number, public range: () => number, public set?: (v: number) => void) {} +} + +interface Instance { + name: string; + def: ScreenDef; + args: any[]; + kw: Props; + frame: Map; + vars: Map; + order: number; + onReturn?: (value: any) => void; + call: boolean; + el: HTMLElement; + timers: number[]; + keys: ((ev: KeyboardEvent) => void)[]; + runners: AtlRunner[]; + /** Timers survive re-evaluation; they are keyed by their node in the screen definition. */ + clocks: Map; + clocksSeen: Set; + /** Final properties of animations that already ran to the end (they must not replay). */ + finished: Map; + /** `on "show"` handlers already run, and `on "hide"` handlers waiting for the screen to go. */ + shownEvents: Set; + hideActions: any[]; +} + +interface Ctx { + /** How the parent places this child. */ + layout: "abs" | "stack" | "col" | "row" | "grid"; + prefix: string; + inst: Instance; +} + +const PX_UNIT = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`); + +function num(v: any): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +export class ScreenManager { + readonly layer: HTMLDivElement; + private shown = new Map(); + private counter = 0; + private pending = 0; + private rendering: Instance | null = null; + tooltip: any = ""; + + constructor(private host: ScreenHost, private screens: Record, + private styles: Record) { + this.layer = document.createElement("div"); + this.layer.className = "pg-usr"; + } + + has(name: string) { + return name in this.screens; + } + + // --- lifecycle ------------------------------------------------------------------- + + show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void } = {}) { + const def = this.screens[name]; + if (!def) throw new Error(`Не найден screen ${name}`); + const previous = this.shown.get(name); + if (previous) this.dispose(previous, false); + const inst: Instance = { + name, def, args, kw, frame: new Map(), vars: previous?.vars ?? new Map(), order: previous?.order ?? ++this.counter, + onReturn: opts.onReturn, call: !!opts.call, el: document.createElement("div"), timers: [], keys: [], runners: [], + clocks: new Map(), clocksSeen: new Set(), finished: previous?.finished ?? new Map(), + shownEvents: previous?.shownEvents ?? new Set(), hideActions: [], + }; + inst.el.className = "pg-sinst"; + this.shown.set(name, inst); + this.layer.append(inst.el); + this.bindParameters(inst); + if (!previous) this.runInside(inst, () => { + for (const [variable, expr] of def.defaults) inst.vars.set(variable, this.host.py.eval(expr)); + }); + this.render(inst); + // `tag`: showing a screen replaces the other one with the same tag + const tag = this.tagOf(inst); + if (tag) for (const other of [...this.shown.values()]) if (other !== inst && this.tagOf(other) === tag) this.hide(other.name); + } + + hide(name: string) { + const inst = this.shown.get(name); + if (inst) this.dispose(inst, true); + } + + hideCall(name: string) { + const inst = this.shown.get(name); + if (inst?.call) this.dispose(inst, true); + } + + /** Called when the script restarts: screens waiting for a return value have no one to answer. */ + hideCalls() { + for (const inst of [...this.shown.values()]) if (inst.call) this.dispose(inst, true); + } + + clear() { + for (const inst of [...this.shown.values()]) this.dispose(inst, true); + this.tooltip = ""; + } + + destroy() { + this.clear(); + this.layer.remove(); + } + + snapshot(): { name: string; args: any[]; kw: Props }[] { + return [...this.shown.values()].filter((i) => !i.call).sort((a, b) => a.order - b.order) + .map((i) => ({ name: i.name, args: i.args, kw: i.kw })); + } + + restore(list: { name: string; args: any[]; kw: Props }[]) { + this.clear(); + for (const item of list) if (this.screens[item.name]) this.show(item.name, item.args, item.kw); + } + + private dispose(inst: Instance, forget: boolean) { + if (forget) for (const action of inst.hideActions) this.run(action, inst); + inst.hideActions = []; + inst.timers.forEach((t) => window.clearTimeout(t)); + inst.timers = []; + inst.clocks.forEach((clock) => window.clearTimeout(clock.handle)); + inst.clocks.clear(); + inst.keys.forEach((k) => window.removeEventListener("keydown", k)); + inst.keys = []; + inst.runners.forEach((r) => r.stop()); + inst.runners = []; + inst.el.remove(); + if (forget) this.shown.delete(inst.name); + } + + /** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */ + refresh() { + if (this.pending || !this.shown.size) return; + this.pending = requestAnimationFrame(() => { + this.pending = 0; + for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst); + }); + } + + // --- evaluation -------------------------------------------------------------------- + + private bindParameters(inst: Instance) { + const { def, args, kw } = inst; + inst.frame.clear(); + def.params.forEach((p, i) => { + if (p.kind === "p" || p.kind === "k") { + if (Object.hasOwn(kw, p.n)) inst.frame.set(p.n, kw[p.n]); + else if (p.kind === "p" && i < args.length) inst.frame.set(p.n, args[i]); + else inst.frame.set(p.n, p.d ? this.runInside(inst, () => this.host.py.eval(p.d)) : null); + } else if (p.kind === "*") inst.frame.set(p.n, args.slice(def.params.filter((q) => q.kind === "p").length)); + else if (p.kind === "**") inst.frame.set(p.n, new PyDict(Object.entries(kw))); + }); + } + + /** Run `fn` with the screen's variables visible to Python expressions. */ + private runInside(inst: Instance, fn: () => T): T { + const py = this.host.py; + const savedLocals = py.locals; + const savedFn = py.fn; + const savedRendering = this.rendering; + py.locals = [...savedLocals, inst.frame, inst.vars]; + py.fn = { vars: inst.vars, globals: new Set(), nonlocals: new Set(), isClassBody: false, index: 0 }; + this.rendering = inst; + try { + return fn(); + } finally { + py.locals = savedLocals; + py.fn = savedFn; + this.rendering = savedRendering; + } + } + + private ev(expr: Expr | null | undefined): any { + if (!expr) return undefined; + try { + return this.host.py.eval(expr); + } catch (err) { + this.host.py.warnOnce(`screen: ${(err as Error).message}`); + return undefined; + } + } + + /** Evaluate with extra variables (an ATL transform's parameters) visible. */ + private evalWith(expr: Expr, frame: Map): any { + const { py } = this.host; + const saved = py.locals; + py.locals = [...saved, frame]; + try { + return this.ev(expr); + } finally { + py.locals = saved; + } + } + + private tagOf(inst: Instance): string { + return this.runInside(inst, () => { + const t = inst.def.props.tag; + return t ? (t.k === "name" ? t.id : pyStr(this.ev(t))) : ""; + }); + } + + // --- rendering ----------------------------------------------------------------------- + + private render(inst: Instance) { + const previousScroll = [...inst.el.querySelectorAll(".pg-sviewport")].map((e) => e.scrollTop); + inst.timers.forEach((t) => window.clearTimeout(t)); + inst.timers = []; + inst.keys.forEach((k) => window.removeEventListener("keydown", k)); + inst.keys = []; + inst.runners.forEach((r) => r.stop()); + inst.runners = []; + inst.clocksSeen.clear(); + inst.hideActions = []; + this.runInside(inst, () => { + const props = inst.def.props; + const zorder = num(this.ev(props.zorder)) ?? 0; + inst.el.style.zIndex = String(zorder * 100 + inst.order); + inst.el.classList.toggle("pg-smodal", truthy(this.ev(props.modal))); + const prefix = props.style_prefix ? pyStr(this.ev(props.style_prefix)) : ""; + const ctx: Ctx = { layout: "abs", prefix, inst }; + const children: HTMLElement[] = []; + this.nodes(inst.def.body, ctx, children); + inst.el.replaceChildren(...children); + }); + for (const [node, clock] of [...inst.clocks]) { + if (!inst.clocksSeen.has(node)) { + window.clearTimeout(clock.handle); + inst.clocks.delete(node); + } + } + [...inst.el.querySelectorAll(".pg-sviewport")].forEach((e, i) => { e.scrollTop = previousScroll[i] ?? 0; }); + } + + private nodes(list: any[], ctx: Ctx, out: HTMLElement[]) { + for (const node of list) { + if (node.w) { + const built = this.widget(node, ctx); + if (built) out.push(built); + } else if (node.c === "if") { + const branch = node.branches.find((b: any) => b.c === null || truthy(this.ev(b.c))); + if (branch) this.nodes(branch.body, ctx, out); + } else if (node.c === "for") { + const items = [...iter(this.ev(node.it))]; + const frame = new Map(); + const { py } = this.host; + py.locals = [...py.locals, frame]; + try { + for (const item of items) { + py.bind(node.t, item, frame); + this.nodes(node.body, ctx, out); + } + } finally { + py.locals = py.locals.slice(0, -1); + } + } else if (node.c === "use") { + this.use(node, ctx, out); + } else if (node.c === "python") { + try { + this.host.py.exec(node.code); + } catch (err) { + this.host.py.warnOnce(`screen python: ${(err as Error).message}`); + } + } else if (node.c === "key") { + this.keyBinding(node, ctx); + } else if (node.c === "timer") { + this.timer(node, ctx); + } else if (node.c === "transclude") { + this.transclude(ctx, out); + } else if (node.c === "on") { + this.event(node, ctx); + } + } + } + + private use(node: any, ctx: Ctx, out: HTMLElement[]) { + const def = this.screens[node.name]; + if (!def) return; + const args = node.args.map((a: Expr) => this.ev(a)); + const kw = Object.fromEntries(Object.entries(node.kw).map(([k, v]) => [k, this.ev(v as Expr)])); + const frame = new Map(); + def.params.forEach((p, i) => { + if (Object.hasOwn(kw, p.n)) frame.set(p.n, kw[p.n]); + else if (i < args.length) frame.set(p.n, args[i]); + else frame.set(p.n, p.d ? this.ev(p.d) : null); + }); + const { py } = this.host; + const saved = py.locals; + if (node.body?.length) this.transclusions.push({ nodes: node.body, locals: [...saved], ctx }); + py.locals = [...saved, frame]; + try { + const prefix = def.props.style_prefix ? pyStr(this.ev(def.props.style_prefix)) : ctx.prefix; + for (const [variable, expr] of def.defaults) if (!frame.has(variable)) frame.set(variable, this.ev(expr)); + this.nodes(def.body, { ...ctx, prefix }, out); + } finally { + py.locals = saved; + if (node.body?.length) this.transclusions.pop(); + } + } + + private transclusions: { nodes: any[]; locals: Map[]; ctx: Ctx }[] = []; + + /** `transclude`: the children given to `use name:` are drawn here, in the caller's scope. */ + private transclude(ctx: Ctx, out: HTMLElement[]) { + const entry = this.transclusions[this.transclusions.length - 1]; + if (!entry) return; + const { py } = this.host; + const saved = py.locals; + const pending = this.transclusions.pop(); + py.locals = entry.locals; + try { + this.nodes(entry.nodes, { ...ctx, prefix: entry.ctx.prefix }, out); + } finally { + py.locals = saved; + if (pending) this.transclusions.push(pending); + } + } + + private event(node: any, ctx: Ctx) { + const inst = ctx.inst; + if (node.event === "show" || node.event === "replace") { + if (inst.shownEvents.has(node)) return; + inst.shownEvents.add(node); + const action = this.ev(node.action); + queueMicrotask(() => this.run(action, inst)); + } else { + inst.hideActions.push(this.ev(node.action)); + } + } + + private keyBinding(node: any, ctx: Ctx) { + const wanted = pyStr(this.ev(node.key)); + const handler = (ev: KeyboardEvent) => { + if (ev.target instanceof HTMLInputElement) return; + if (!matchesKey(wanted, ev)) return; + ev.preventDefault(); + this.run(this.ev(node.action), ctx.inst); + }; + window.addEventListener("keydown", handler); + ctx.inst.keys.push(handler); + } + + private timer(node: any, ctx: Ctx) { + const inst = ctx.inst; + inst.clocksSeen.add(node); + if (inst.clocks.has(node)) return; // already running (or finished): re-rendering must not restart it + const seconds = num(this.ev(node.delay)); + if (seconds === null) return; + const repeat = truthy(this.ev(node.repeat)); + const clock = { handle: 0, finished: false }; + const fire = () => { + if (this.shown.get(inst.name) !== inst) return; + // the action is evaluated now, with the state the screen has at this moment + this.runInside(inst, () => this.run(this.ev(node.action), inst)); + if (repeat && this.shown.get(inst.name) === inst) clock.handle = window.setTimeout(fire, seconds * 1000); + else clock.finished = true; + }; + clock.handle = window.setTimeout(fire, seconds * 1000); + inst.clocks.set(node, clock); + } + + // --- styles -------------------------------------------------------------------------- + + /** Properties of a named style, parents first; values are still expressions. */ + private styleProps(name: string, seen = new Set()): Record { + const style = this.styles[name]; + if (!style || seen.has(name)) return {}; + seen.add(name); + return { ...(style.is ? this.styleProps(style.is, seen) : {}), ...style.props }; + } + + /** Node properties on top of the styles that apply to this kind of widget. */ + private resolve(node: any, ctx: Ctx, styleKinds: string[], own?: Props): Record { + const merged: Record = {}; + for (const kind of styleKinds) Object.assign(merged, this.styleProps(kind)); + if (ctx.prefix) for (const kind of styleKinds) Object.assign(merged, this.styleProps(`${ctx.prefix}_${kind}`)); + const explicit = node.props.style ? pyStr(this.ev(node.props.style)) : ""; + if (explicit) Object.assign(merged, this.styleProps(explicit)); + Object.assign(merged, node.props); + // `if` inside the widget that only sets properties + for (const group of (node.propIf ?? []) as { c: Expr | null; props: Record }[][]) { + const branch = group.find((b) => b.c === null || truthy(this.ev(b.c))); + if (branch) Object.assign(merged, branch.props); + } + return own ? { ...merged, ...own } : merged; + } + + private pick(props: Record, name: string, state = "idle"): any { + const order = state === "idle" ? ["idle", ""] : + state === "hover" ? ["hover", ""] : + state === "insensitive" ? ["insensitive", ""] : + state === "selected_idle" ? ["selected_idle", "selected", "idle", ""] : + state === "selected_hover" ? ["selected_hover", "selected", "hover", ""] : ["selected_insensitive", "insensitive", ""]; + for (const prefix of order) { + const key = prefix ? `${prefix}_${name}` : name; + if (key in props) return this.ev(props[key]); + } + return undefined; + } + + // --- widgets ------------------------------------------------------------------------- + + private widget(node: any, ctx: Ctx): HTMLElement | null { + switch (node.w) { + case "fixed": return this.container(node, ctx, "div", "abs", ["fixed"], true); + case "frame": + case "window": return this.container(node, ctx, "div", "stack", [node.w, "frame"]); + case "vbox": return this.container(node, ctx, "div", "col", ["vbox"]); + case "hbox": return this.container(node, ctx, "div", "row", ["hbox"]); + case "grid": return this.grid(node, ctx); + case "viewport": return this.viewport(node, ctx); + case "vpgrid": return this.vpgrid(node, ctx); + case "button": return this.button(node, ctx, "div", ["button"], null); + case "textbutton": return this.button(node, ctx, "div", ["button"], this.ev(node.args[0])); + case "imagebutton": return this.imageButton(node, ctx); + case "text": return this.text(node, ctx, ["text"], this.ev(node.args[0])); + case "label": return this.text(node, ctx, ["text", "label_text"], this.ev(node.args[0]), "pg-slabel"); + case "add": return this.add(node, ctx); + case "null": return this.nullWidget(node, ctx); + case "bar": + case "vbar": return this.bar(node, ctx); + } + return null; + } + + private container(node: any, ctx: Ctx, tag: string, layout: Ctx["layout"], kinds: string[], fill = false): HTMLElement { + const el = document.createElement(tag); + const props = this.resolve(node, ctx, kinds); + el.className = `pg-sw pg-s${node.w}`; + const style = el.style; + if (layout === "abs") style.position = "relative"; + if (layout === "col" || layout === "row") { + style.display = "flex"; + style.flexDirection = layout === "col" ? "column" : "row"; + const gap = num(this.pick(props, "spacing")); + if (gap !== null) style.gap = `${gap}px`; + if (truthy(this.pick(props, "box_reverse"))) style.flexDirection += "-reverse"; + if (truthy(this.pick(props, "box_wrap"))) style.flexWrap = "wrap"; + } else if (layout === "stack") { + style.display = "grid"; + } else if (layout === "grid") { + style.display = "grid"; + const cols = num(this.pick(props, "cols")); + const rows = num(this.pick(props, "rows")); + if (cols) style.gridTemplateColumns = `repeat(${cols}, auto)`; + else if (rows) style.gridAutoFlow = "column"; + if (rows) style.gridTemplateRows = `repeat(${rows}, auto)`; + const gap = num(this.pick(props, "spacing")); + if (gap !== null) style.gap = `${gap}px`; + } + this.layout(el, props, ctx, { fill: fill && !("xsize" in props || "xysize" in props) }); + this.look(el, props, "idle"); + const inner: Ctx = { ...ctx, layout }; + const children: HTMLElement[] = []; + this.nodes(node.body, inner, children); + for (const child of children) { + if (layout === "stack") child.style.gridArea = "1 / 1"; + el.append(child); + } + this.transforms(el, props, ctx); + return el; + } + + private grid(node: any, ctx: Ctx): HTMLElement { + const el = document.createElement("div"); + const props = this.resolve(node, ctx, ["grid"]); + const [cols, rows] = [num(this.ev(node.args[0])) ?? 1, num(this.ev(node.args[1])) ?? 1]; + el.className = "pg-sw pg-sgrid"; + el.style.display = "grid"; + el.style.gridTemplateColumns = `repeat(${cols}, auto)`; + el.style.gridTemplateRows = `repeat(${rows}, auto)`; + const gap = num(this.pick(props, "spacing")); + const xgap = num(this.pick(props, "xspacing")) ?? gap; + const ygap = num(this.pick(props, "yspacing")) ?? gap; + if (xgap !== null) el.style.columnGap = `${xgap}px`; + if (ygap !== null) el.style.rowGap = `${ygap}px`; + this.layout(el, props, ctx, { fill: false }); + this.look(el, props, "idle"); + const children: HTMLElement[] = []; + this.nodes(node.body, { ...ctx, layout: "grid" }, children); + el.append(...children); + this.transforms(el, props, ctx); + return el; + } + + private vpgrid(node: any, ctx: Ctx): HTMLElement { + const el = this.container(node, ctx, "div", "grid", ["vpgrid", "viewport"]); + el.classList.add("pg-sviewport"); + el.style.overflowY = "auto"; + return el; + } + + private viewport(node: any, ctx: Ctx): HTMLElement { + const el = this.container(node, ctx, "div", "col", ["viewport"]); + el.classList.add("pg-sviewport"); + const props = this.resolve(node, ctx, ["viewport"]); + const mode = pyStr(this.pick(props, "scrollbars") ?? ""); + el.style.overflowY = mode.includes("vertical") || this.pick(props, "mousewheel") ? "auto" : "hidden"; + el.style.overflowX = mode.includes("horizontal") ? "auto" : "hidden"; + return el; + } + + private text(node: any, ctx: Ctx, kinds: string[], value: any, cls = "", forwarded?: Props): HTMLElement { + const el = document.createElement("div"); + el.className = `pg-sw pg-stext ${cls}`.trim(); + const props = this.resolve(node, ctx, kinds, forwarded); + this.layout(el, props, ctx, { fill: false }); + this.textLook(el, props, "idle"); + const size = num(this.pick(props, "size")) ?? this.host.baseSize; + this.host.text(el, value === null || value === undefined ? "" : pyStr(value), size); + this.transforms(el, props, ctx); + return el; + } + + private nullWidget(node: any, ctx: Ctx): HTMLElement { + const el = document.createElement("div"); + const props = this.resolve(node, ctx, []); + el.className = "pg-sw pg-snull"; + this.layout(el, props, ctx, { fill: false }); + return el; + } + + private add(node: any, ctx: Ctx): HTMLElement | null { + const value = this.ev(node.args[0]); + if (value === null || value === undefined) return null; + const props = this.resolve(node, ctx, []); + const picture = this.displayable(value); + if (!picture) return null; + const el = this.cropped(picture, this.pick(props, "crop")); + el.classList.add("pg-sw", "pg-sadd"); + this.layout(el, props, ctx, { fill: false }); + this.transforms(el, props, ctx); + return el; + } + + private bar(node: any, ctx: Ctx): HTMLElement { + const el = document.createElement("div"); + const props = this.resolve(node, ctx, ["bar"]); + el.className = "pg-sw pg-sbar"; + const vertical = truthy(this.ev(node.props.vertical)); + this.layout(el, props, ctx, { fill: false }); + this.look(el, props, "idle"); + const fill = document.createElement("div"); + fill.className = "pg-sbar-fill"; + let value = num(this.pick(props, "value")) ?? 0; + let range = num(this.pick(props, "range")) ?? 1; + const source = this.pick(props, "value"); + if (source instanceof BarValue) { + value = source.value(); + range = source.range(); + } + const ratio = range > 0 ? Math.max(0, Math.min(1, value / range)) : 0; + fill.style[vertical ? "height" : "width"] = `${ratio * 100}%`; + el.append(fill); + if (source instanceof BarValue && source.set) { + const set = source.set; + const apply = (ev: PointerEvent) => { + const box = el.getBoundingClientRect(); + const t = vertical ? 1 - (ev.clientY - box.top) / box.height : (ev.clientX - box.left) / box.width; + set(Math.max(0, Math.min(1, t)) * source.range()); + this.refresh(); + }; + el.onpointerdown = (ev) => { ev.stopPropagation(); el.setPointerCapture(ev.pointerId); apply(ev); }; + el.onpointermove = (ev) => { if (el.hasPointerCapture(ev.pointerId)) apply(ev); }; + } + return el; + } + + // --- displayables -------------------------------------------------------------------- + + /** An element for an image name/path or for a Crop/Solid/Frame/Transform built by game code. */ + private displayable(value: any, depth = 0): HTMLElement | null { + if (depth > 8 || value === null || value === undefined) return null; + if (!(value instanceof Displayable)) return this.host.image(pyStr(value)); + const [a, b] = value.args; + switch (value.kind) { + case "crop": { + const child = this.displayable(b, depth + 1); + return child && Array.isArray(a) ? this.cropped(child, a) : child; + } + case "solid": { + const box = document.createElement("div"); + box.style.background = pyStr(a); + box.style.width = box.style.height = "100%"; + return box; + } + case "frame": { + const box = document.createElement("div"); + this.paintBackground(box, a); + box.style.width = box.style.height = "100%"; + return box; + } + case "null": { + const box = document.createElement("div"); + if (num(value.kw.width ?? a) !== null) box.style.width = `${value.kw.width ?? a}px`; + if (num(value.kw.height ?? b) !== null) box.style.height = `${value.kw.height ?? b}px`; + return box; + } + case "dynamic": { + const result = typeof a === "function" ? a(0, 0) : null; + return this.displayable(Array.isArray(result) ? result[0] : result, depth + 1); + } + case "transform": { + const child = this.displayable(a, depth + 1); + if (!child) return null; + const p = value.kw; + const parts: string[] = []; + const zoom = (p.zoom ?? 1); + if (zoom !== 1 || p.xzoom || p.yzoom) parts.push(`scale(${zoom * (p.xzoom ?? 1)}, ${zoom * (p.yzoom ?? 1)})`); + if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); + child.style.transform = parts.join(" "); + if (p.alpha !== undefined) child.style.opacity = String(p.alpha); + return child; + } + } + return null; + } + + /** `crop (x, y, w, h)`: show only that part of the picture. */ + private cropped(el: HTMLElement, rect: any): HTMLElement { + if (!Array.isArray(rect) || rect.length !== 4 || rect.some((n) => num(n) === null)) return el; + const [x, y, w, h] = rect as number[]; + const box = document.createElement("div"); + box.style.overflow = "hidden"; + box.style.width = `${w}px`; + box.style.height = `${h}px`; + box.style.position = "relative"; + el.style.position = "absolute"; + el.style.left = `${-x}px`; + el.style.top = `${-y}px`; + el.style.maxWidth = "none"; + box.append(el); + return box; + } + + // --- buttons ------------------------------------------------------------------------- + + /** `text` is null for a plain `button`, whose children are laid out inside. */ + private button(node: any, ctx: Ctx, tag: string, kinds: string[], text: any): HTMLElement { + const el = document.createElement(tag); + const props = this.resolve(node, ctx, kinds); + el.className = "pg-sw pg-sbtn"; + el.tabIndex = 0; + el.setAttribute("role", "button"); + el.style.display = "grid"; + this.layout(el, props, ctx, { fill: false }); + const textNode: Props = {}; + for (const [key, value] of Object.entries(props)) { + if (key.startsWith("text_")) textNode[key.slice(5)] = value; + else if (/^(idle|hover|insensitive|selected|selected_idle|selected_hover)_(color|size|font|bold|italic|outlines)$/.test(key)) + textNode[key] = value; + } + let label: HTMLElement | null = null; + if (text !== null) { + label = this.text({ props: {} }, { ...ctx, layout: "stack" }, ["text", "button_text"], text, "", textNode); + label.style.gridArea = "1 / 1"; + el.append(label); + } else { + const children: HTMLElement[] = []; + this.nodes(node.body, { ...ctx, layout: "stack" }, children); + for (const child of children) { + child.style.gridArea = "1 / 1"; + el.append(child); + } + } + this.wireButton(el, props, ctx, label, textNode, null); + this.transforms(el, props, ctx); + return el; + } + + private imageButton(node: any, ctx: Ctx): HTMLElement { + const el = document.createElement("div"); + const props = this.resolve(node, ctx, ["image_button", "button"]); + el.className = "pg-sw pg-sbtn pg-simgbtn"; + el.tabIndex = 0; + el.setAttribute("role", "button"); + el.style.display = "grid"; + this.layout(el, props, ctx, { fill: false }); + const auto = this.pick(props, "auto"); + const image = (state: string): any => { + const direct = this.pick(props, state === "idle" ? "idle" : state); + if (typeof direct === "string" || direct instanceof Displayable) return direct; + if (typeof auto === "string") return auto.replace("%s", state); + return null; + }; + const images = { + idle: image("idle"), hover: image("hover") ?? image("idle"), insensitive: image("insensitive") ?? image("idle"), + selected_idle: image("selected_idle") ?? image("idle"), selected_hover: image("selected_hover") ?? image("hover") ?? image("idle"), + selected_insensitive: image("selected_insensitive") ?? image("insensitive") ?? image("idle"), + } as Record; + this.wireButton(el, props, ctx, null, {}, images); + this.transforms(el, props, ctx); + return el; + } + + /** Behaviour shared by all buttons: states, actions, sounds, tooltips. */ + private wireButton(el: HTMLElement, props: Record, ctx: Ctx, label: HTMLElement | null, + textProps: Props, images: Record | null) { + const action = this.ev(props.action); + const sensitive = props.sensitive ? truthy(this.ev(props.sensitive)) && this.actionSensitive(action) : this.actionSensitive(action); + const selected = props.selected ? truthy(this.ev(props.selected)) : this.actionSelected(action); + let hover = false; + const paint = () => { + const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive") + : selected ? (hover ? "selected_hover" : "selected_idle") : hover ? "hover" : "idle"; + el.dataset.state = state; + if (images) { + const file = images[state]; + const picture = file ? this.displayable(file) : null; + el.replaceChildren(...(picture ? [picture] : [])); + picture?.classList.add("pg-simg"); + } else { + this.look(el, props, state); + if (label) this.textLook(label, { ...props, ...Object.fromEntries(Object.entries(textProps).map(([k, v]) => [k, v])) }, state); + } + }; + paint(); + el.classList.toggle("pg-sdisabled", !sensitive); + el.classList.toggle("pg-sselected", selected); + const hoverSound = props.hover_sound ? this.ev(props.hover_sound) : null; + const clickSound = props.activate_sound ? this.ev(props.activate_sound) : null; + const tooltip = props.tooltip ? this.ev(props.tooltip) : undefined; + el.onmouseenter = () => { + if (!sensitive) return; + hover = true; + paint(); + if (typeof hoverSound === "string") this.host.play("sound", hoverSound, false); + if (tooltip !== undefined && this.tooltip !== tooltip) { this.tooltip = tooltip; this.refresh(); } + this.run(this.ev(props.hovered), ctx.inst); + }; + el.onmouseleave = () => { + if (!hover) return; + hover = false; + paint(); + if (tooltip !== undefined && this.tooltip === tooltip) { this.tooltip = ""; this.refresh(); } + this.run(this.ev(props.unhovered), ctx.inst); + }; + const activate = (ev: Event) => { + ev.stopPropagation(); + if (!sensitive) return; + if (typeof clickSound === "string") this.host.play("sound", clickSound, false); + this.run(action, ctx.inst); + }; + el.onclick = activate; + el.onkeydown = (ev) => { if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); activate(ev); } }; + } + + // --- look & layout ------------------------------------------------------------------- + + private look(el: HTMLElement, props: Record, state: string) { + const background = this.pick(props, "background", state); + el.style.background = ""; + el.style.backgroundImage = ""; + if (typeof background === "string") this.paintBackground(el, background); + else if (background instanceof Displayable) { + if (background.kind === "solid") el.style.background = pyStr(background.args[0]); + else if (background.kind === "frame") this.paintBackground(el, pyStr(background.args[0])); + } + const alpha = num(this.pick(props, "alpha")); + if (alpha !== null) el.style.opacity = String(alpha); + } + + private paintBackground(el: HTMLElement, value: string) { + if (/^#[0-9a-f]{3,8}$/i.test(value)) el.style.background = value; + else { + const url = this.host.fileUrl(value) ?? this.imageUrl(value); + if (url) { + el.style.backgroundImage = `url("${url}")`; + el.style.backgroundSize = "100% 100%"; + el.style.backgroundRepeat = "no-repeat"; + } + } + } + + private imageUrl(name: string): string | null { + const probe = this.host.image(name); + if (probe instanceof HTMLImageElement) return probe.src || null; + return null; + } + + private textLook(el: HTMLElement, props: Record, state: string) { + const color = this.pick(props, "color", state); + if (typeof color === "string") el.style.color = color; + const size = num(this.pick(props, "size", state)); + if (size !== null) el.style.fontSize = `${size}px`; + const font = this.pick(props, "font", state); + if (typeof font === "string") { + const family = this.host.fontFamily(font); + if (family) el.style.fontFamily = `"${family}"`; + } + if (truthy(this.pick(props, "bold", state))) el.style.fontWeight = "bold"; + if (truthy(this.pick(props, "italic", state))) el.style.fontStyle = "italic"; + if (truthy(this.pick(props, "underline", state))) el.style.textDecoration = "underline"; + const align = this.pick(props, "text_align") ?? this.pick(props, "textalign"); + if (num(align) !== null) el.style.textAlign = align <= 0 ? "left" : align >= 1 ? "right" : "center"; + const spacing = num(this.pick(props, "line_spacing")); + if (spacing !== null) el.style.lineHeight = `calc(1em + ${spacing}px)`; + const outlines = this.pick(props, "outlines"); + if (Array.isArray(outlines) && outlines.length) { + const [width, colour] = outlines[0] as [number, string]; + if (typeof colour === "string" && width > 0) el.style.textShadow = `0 0 ${width}px ${colour}, 0 0 ${width}px ${colour}`; + } + } + + private layout(el: HTMLElement, props: Record, ctx: Ctx, opts: { fill: boolean }) { + const g = (name: string) => this.pick(props, name); + const style = el.style; + const alpha = num(g("alpha")); + if (alpha !== null) style.opacity = String(alpha); + // size + let [width, height] = [g("xsize") ?? g("width"), g("ysize") ?? g("height")]; + const both = g("xysize"); + if (Array.isArray(both)) [width, height] = both; + const area = g("area"); + if (Array.isArray(area) && area.length === 4) { + style.position = "absolute"; + style.left = lengthOf(area[0]); + style.top = lengthOf(area[1]); + width = area[2]; + height = area[3]; + } + if (num(width) !== null) style.width = lengthOf(width); + else if (truthy(g("xfill")) || opts.fill) style.width = "100%"; + if (num(height) !== null) style.height = lengthOf(height); + else if (truthy(g("yfill")) || opts.fill) style.height = "100%"; + if (num(g("xminimum")) !== null) style.minWidth = lengthOf(g("xminimum")); + if (num(g("yminimum")) !== null) style.minHeight = lengthOf(g("yminimum")); + if (num(g("xmaximum")) !== null) style.maxWidth = lengthOf(g("xmaximum")); + if (num(g("ymaximum")) !== null) style.maxHeight = lengthOf(g("ymaximum")); + style.boxSizing = "border-box"; + // padding and margin + this.box(style, "padding", props); + this.box(style, "margin", props); + // position + if (Array.isArray(area)) return; + this.position(el, props, ctx); + } + + private box(style: CSSStyleDeclaration, kind: "padding" | "margin", props: Record) { + const g = (name: string) => this.pick(props, name); + const all = g(kind); + let [left, top, right, bottom] = [0, 0, 0, 0]; + if (num(all) !== null) [left, top, right, bottom] = [all, all, all, all]; + else if (Array.isArray(all) && all.length === 2) [left, right, top, bottom] = [all[0], all[0], all[1], all[1]]; + else if (Array.isArray(all) && all.length === 4) [left, top, right, bottom] = all; + const x = num(g(`x${kind}`)); + const y = num(g(`y${kind}`)); + if (x !== null) [left, right] = [x, x]; + if (y !== null) [top, bottom] = [y, y]; + left = num(g(`left_${kind}`)) ?? left; + right = num(g(`right_${kind}`)) ?? right; + top = num(g(`top_${kind}`)) ?? top; + bottom = num(g(`bottom_${kind}`)) ?? bottom; + if (left || top || right || bottom) style[kind] = `${top}px ${right}px ${bottom}px ${left}px`; + } + + private position(el: HTMLElement, props: Record, ctx: Ctx) { + const g = (name: string) => this.pick(props, name); + const pair = (name: string): [any, any] => { const v = g(name); return Array.isArray(v) ? [v[0], v[1]] : [undefined, undefined]; }; + const [apx, apy] = pair("align"); + const [ppx, ppy] = pair("pos"); + const [anx, any] = pair("anchor"); + let xalign = g("xalign") ?? apx; + let yalign = g("yalign") ?? apy; + const xcenter = g("xcenter"); + const ycenter = g("ycenter"); + let xpos = g("xpos") ?? ppx; + let ypos = g("ypos") ?? ppy; + let xanchor = g("xanchor") ?? anx; + let yanchor = g("yanchor") ?? any; + if (num(xalign) !== null) { xpos = xalign; xanchor = xalign; } + if (num(yalign) !== null) { ypos = yalign; yanchor = yalign; } + if (num(xcenter) !== null) { xpos = xcenter; xanchor = 0.5; } + if (num(ycenter) !== null) { ypos = ycenter; yanchor = 0.5; } + const xoffset = num(g("xoffset")) ?? 0; + const yoffset = num(g("yoffset")) ?? 0; + const style = el.style; + const hasX = num(xpos) !== null; + const hasY = num(ypos) !== null; + if (ctx.layout === "abs") { + if (!hasX && !hasY && !xoffset && !yoffset && num(xanchor) === null) return; + style.position = "absolute"; + style.left = `calc(${lengthOf(xpos ?? 0)} + ${xoffset}px)`; + style.top = `calc(${lengthOf(ypos ?? 0)} + ${yoffset}px)`; + const ax = anchorOf(xanchor); + const ay = anchorOf(yanchor); + if (ax !== "0%" || ay !== "0%") style.translate = `-${ax} -${ay}`; + return; + } + // flowing layouts: alignment inside the cell, offsets relative to the flow position + const horizontal = num(xalign) !== null ? alignName(xalign) : null; + const vertical = num(yalign) !== null ? alignName(yalign) : null; + if (ctx.layout === "col" && horizontal) style.alignSelf = horizontal; + else if (ctx.layout === "row" && vertical) style.alignSelf = vertical; + else if (ctx.layout === "stack" || ctx.layout === "grid") { + if (horizontal) style.justifySelf = horizontal; + if (vertical) style.alignSelf = vertical; + } + if (xoffset || yoffset || (hasX && num(xalign) === null) || (hasY && num(yalign) === null)) { + style.position = "relative"; + if (hasX && num(xalign) === null) style.left = lengthOf(xpos); + if (hasY && num(yalign) === null) style.top = lengthOf(ypos); + if (xoffset) style.marginLeft = `${xoffset}px`; + if (yoffset) style.marginTop = `${yoffset}px`; + } + } + + /** `at` transforms: static properties now, animated ones through the ATL runner. */ + private transforms(el: HTMLElement, props: Record, ctx: Ctx) { + const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at : []; + const live: Props = {}; + const apply = (p: Props) => { + const zoom = (p.zoom ?? 1); + const xz = zoom * (p.xzoom ?? 1); + const yz = zoom * (p.yzoom ?? 1); + const parts: string[] = []; + if (p.xoffset || p.yoffset) parts.push(`translate(${p.xoffset ?? 0}px, ${p.yoffset ?? 0}px)`); + if (xz !== 1 || yz !== 1) parts.push(`scale(${xz}, ${yz})`); + if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); + el.style.transform = parts.join(" "); + if (p.alpha !== undefined) el.style.opacity = String(p.alpha); + if (typeof p.blur === "number") el.style.filter = p.blur > 0 ? `blur(${p.blur}px)` : ""; + }; + for (const spec of specs) { + Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {}); + } + if (specs.length) apply(live); + for (const spec of specs) { + const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl; + if (!block) continue; + const done = ctx.inst.finished.get(spec); + if (done) { + Object.assign(live, done); + apply(live); + continue; + } + const runner = new AtlRunner({ + block: (name) => this.host.atl(name), + props: (name) => this.host.transform(name), + apply: (p) => apply(p), + eval: (expr, frame) => this.evalWith(expr, frame), + }, live, bindArguments(block, spec, (e) => this.ev(e))); + ctx.inst.runners.push(runner); + const inst = ctx.inst; + void runner.start(block).then((completed) => { + if (completed && block.repeat === null) inst.finished.set(spec, { ...live }); + }).catch(() => {}); + } + } + + // --- actions ------------------------------------------------------------------------- + + private actionSensitive(action: any): boolean { + const list = Array.isArray(action) ? action : [action]; + return list.every((a) => !(a instanceof ScreenAction) || a.kind !== "SensitiveIf" || truthy(a.args[0])); + } + + private actionSelected(action: any): boolean { + const list = Array.isArray(action) ? action : [action]; + return list.some((a) => { + if (!(a instanceof ScreenAction)) return false; + switch (a.kind) { + case "SelectedIf": return truthy(a.args[0]); + case "SetVariable": return this.valueOf(a.args[0], a.screen) === a.args[1]; + case "SetScreenVariable": return a.screen?.vars.get(a.args[0]) === a.args[1]; + case "ToggleVariable": return truthy(this.valueOf(a.args[0], a.screen)); + case "ToggleScreenVariable": return truthy(a.screen?.vars.get(a.args[0])); + default: return false; + } + }); + } + + private valueOf(path: string, screen: Instance | null): any { + const [root, ...rest] = String(path).split("."); + let v = screen?.vars.has(root) ? screen.vars.get(root) : this.host.py.lookup(root); + for (const part of rest) v = v instanceof PyDict ? v.get(part) : v?.[part]; + return v ?? null; + } + + private assign(path: string, value: any, screen: Instance | null) { + const parts = String(path).split("."); + if (parts.length === 1 && screen?.vars.has(parts[0])) { + screen.vars.set(parts[0], value); + return; + } + let target: Expr = { k: "name", id: parts[0] }; + for (const part of parts.slice(1)) target = { k: "attr", o: target, a: part }; + this.host.py.bind(target, value); + } + + /** Run what a widget's `action` evaluated to. */ + run(action: any, screen: Instance | null) { + // actions act on the game, not on the screen's own variable scope + const { py } = this.host; + const savedFn = py.fn; + py.fn = null; + try { + this.perform(action, screen); + } catch (err) { + if (!(err instanceof ActionAbort)) console.warn("[pgvn] действие screen", err); + } finally { + py.fn = savedFn; + } + this.refresh(); + } + + private perform(action: any, screen: Instance | null) { + if (action === null || action === undefined || action === false) return; + if (Array.isArray(action)) { + for (const item of action) this.perform(item, screen); + return; + } + if (typeof action === "function") { + action(); + return; + } + if (!(action instanceof ScreenAction)) return; + const [a, b, c] = action.args; + const owner = action.screen ?? screen; + const { py } = this.host; + switch (action.kind) { + case "Jump": this.host.jump(String(a)); throw new ActionAbort(); + case "Call": this.host.call(String(a)); throw new ActionAbort(); + case "Return": + if (owner?.onReturn) { + owner.onReturn(a ?? null); + throw new ActionAbort(); + } + return; + case "SetVariable": this.assign(a, b, owner); return; + case "ToggleVariable": { + const now = this.valueOf(a, owner); + const on = action.args.length > 1 ? b : true; + const off = action.args.length > 2 ? c : false; + this.assign(a, truthy(now) && now !== off ? off : on, owner); + return; + } + case "SetScreenVariable": owner?.vars.set(a, b); return; + case "ToggleScreenVariable": { + const now = owner?.vars.get(a); + const on = action.args.length > 1 ? b : true; + const off = action.args.length > 2 ? c : false; + owner?.vars.set(a, truthy(now) && now !== off ? off : on); + return; + } + case "SetDict": + if (a instanceof PyDict) a.set(b, c); + else if (a) a[b] = c; + return; + case "SetField": if (a) a[b] = c; return; + case "ToggleField": if (a) a[b] = !truthy(a[b]); return; + case "Show": + case "ShowTransient": this.show(String(a), action.args.slice(1).filter((x) => typeof x !== "object" || x === null || Array.isArray(x) || x instanceof PyDict), action.kw); return; + case "Hide": this.hide(String(a)); return; + case "Function": { + const rest = action.args.slice(1); + if (typeof a === "function") a(...rest, ...(Object.keys(action.kw).length ? [{ __kw: action.kw }] : [])); + return; + } + case "Play": this.host.play(String(a), String(b), false); return; + case "Queue": this.host.play(String(a), String(b), false); return; + case "Stop": this.host.stop(String(a)); return; + case "Notify": this.host.notify(pyStr(a)); return; + case "ToggleScreen": + if (this.shown.has(String(a))) this.hide(String(a)); + else this.show(String(a), action.args.slice(1), action.kw); + return; + case "Language": this.host.setLanguage(a === null || a === undefined ? null : String(a)); return; + case "ShowMenu": this.host.openMenu(); throw new ActionAbort(); + case "OpenURL": this.host.openUrl(String(a)); return; + case "Quit": this.host.quit(); throw new ActionAbort(); + case "MainMenu": this.host.mainMenu(); throw new ActionAbort(); + case "NullAction": + case "SensitiveIf": + case "SelectedIf": return; + } + void py; + } + + /** Python-visible constructors of actions and bar values. */ + builtins(): Record { + const make = (kind: string) => (...args: any[]) => { + const last = args[args.length - 1]; + const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); + return new ScreenAction(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}, this.rendering); + }; + const out: Record = {}; + for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField", + "SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play", + "Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu", + "OpenURL"]) out[kind] = make(kind); + const shape = (kind: string) => (...args: any[]) => { + const last = args[args.length - 1]; + const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); + return new Displayable(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {}); + }; + out.Crop = shape("crop"); + out.LiveCrop = shape("crop"); + out.Solid = shape("solid"); + out.Frame = shape("frame"); + out.Null = shape("null"); + out.Transform = shape("transform"); + out.DynamicDisplayable = shape("dynamic"); + out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value)); + out.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no); + out.GetTooltip = () => this.tooltip; + out.StaticValue = (value: number, range = 1) => new BarValue(() => value, () => range); + out.AnimatedValue = out.StaticValue; + const screen = () => this.rendering; + out.VariableValue = (variable: string, range = 1) => + new BarValue(() => Number(this.valueOf(variable, screen())) || 0, () => range, (v) => this.assign(variable, v, screen())); + out.FieldValue = (object: any, field: string, range = 1) => + new BarValue(() => Number(object?.[field]) || 0, () => range, (v) => { if (object) object[field] = v; }); + return out; + } + + /** `renpy.show_screen` and friends. */ + renpyApi(): Record { + return { + show_screen: (...a: any[]) => { + const last = a[a.length - 1]; + const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {}; + const args = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? a.slice(1, -1) : a.slice(1); + this.show(String(a[0]), args, kw); + return null; + }, + hide_screen: (name: string) => { this.hide(String(name)); return null; }, + get_screen: (name: string) => (this.shown.has(name) ? true : null), + restart_interaction: () => { this.refresh(); return null; }, + }; + } +} + +function lengthOf(v: any): string { + return typeof v === "number" ? PX_UNIT(v) : "0px"; +} + +function anchorOf(v: any): string { + return typeof v === "number" ? PX_UNIT(v) : "0%"; +} + +function alignName(v: number): string { + return v <= 0 ? "start" : v >= 1 ? "end" : "center"; +} + +function* iter(v: any): Iterable { + if (Array.isArray(v) || typeof v === "string") yield* v; + else if (v instanceof PyDict) yield* v.map.keys(); + else if (v && typeof v[Symbol.iterator] === "function") yield* v; +} + +function matchesKey(wanted: string, ev: KeyboardEvent): boolean { + const name = wanted.replace(/^K_/, "").toLowerCase(); + const map: Record = { escape: "escape", return: "enter", space: "space", left: "arrowleft", right: "arrowright", + up: "arrowup", down: "arrowdown", tab: "tab", backspace: "backspace", delete: "delete" }; + const key = ev.key.toLowerCase() === " " ? "space" : ev.key.toLowerCase(); + return key === (map[name] ?? name); +} diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index f83ed52..b874836 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -53,6 +53,9 @@ export interface AtSpec { props?: Record; /** Animated transform: a name from GameData.atl or an inline timeline. */ atl?: string | import("./atl").AtlBlock; + /** Arguments of `at name(args)` for a parameterised transform. */ + args?: Expr[]; + kw?: Record; effect?: "shake"; d?: number; dist?: number; @@ -109,6 +112,8 @@ export interface GameData { images: Record; transforms: Record>; atl?: Record; + screens?: Record; + styles?: Record; transitions: Record; audio: Record; channels?: Record; @@ -155,4 +160,5 @@ export interface SaveData { camera?: AtSpec[]; menuSeen?: string[]; text?: string; + screens?: { name: string; args: any[]; kw: Record }[]; } diff --git a/frontend/tests/fixtures/screens.json b/frontend/tests/fixtures/screens.json new file mode 100644 index 0000000..fe601eb --- /dev/null +++ b/frontend/tests/fixtures/screens.json @@ -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":"SetVariable"},"a":[{"k":"const","v":"mood_clicked"},{"k":"const","v":1}],"kw":{}},{"k":"call","f":{"k":"name","id":"SetVariable"},"a":[{"k":"const","v":"mood_done"},{"k":"const","v":true}],"kw":{}}]}},"body":[],"propIf":[[{"c":{"k":"cmp","l":{"k":"name","id":"money"},"ops":[">"],"r":[{"k":"const","v":10}]},"props":{"idle":{"k":"const","v":"rich.png"}}},{"c":null,"props":{"idle":{"k":"const","v":"poor.png"}}}]]}]},"shop":{"params":[{"n":"item","d":{"k":"const","v":"none"},"kind":"p"}],"props":{"modal":{"k":"const","v":true}},"defaults":[],"body":[{"w":"imagebutton","args":[],"props":{"auto":{"k":"const","v":"buy_%s.png"},"action":{"k":"call","f":{"k":"name","id":"Return"},"a":[{"k":"name","id":"item"}],"kw":{}},"at":[{"props":{"alpha":0.0},"name":"fade_in","atl":"fade_in"}]},"body":[]},{"w":"textbutton","args":[{"k":"const","v":"Cancel"}],"props":{"action":{"k":"call","f":{"k":"name","id":"Return"},"a":[{"k":"const","v":null}],"kw":{}},"sensitive":{"k":"name","id":"can_cancel"}},"body":[]}]}},"styles":{"hud_text":{"is":null,"props":{"size":{"k":"const","v":30},"color":{"k":"const","v":"#abc"}}},"hud_button":{"is":null,"props":{"background":{"k":"const","v":"#123"},"hover_background":{"k":"const","v":"#456"},"padding":{"k":"tuple","v":[{"k":"const","v":10},{"k":"const","v":4}]}}}},"atl":{"fade_in":{"steps":[{"op":"set","props":{"alpha":0.0}},{"op":"move","warp":"linear","t":0.1,"props":{"alpha":1.0}}],"repeat":null},"blink":{"steps":[{"op":"set","props":{"alpha":0.0}},{"op":"pause","t":{"e":{"k":"name","id":"delay"}}},{"op":"move","warp":"linear","t":{"e":{"k":"name","id":"cycle"}},"props":{"alpha":1.0}}],"repeat":null,"params":[{"n":"delay","d":null,"kind":"p"},{"n":"cycle","d":{"k":"const","v":1.0},"kind":"p"}]}},"transforms":{"fade_in":{"alpha":0.0},"blink":{"alpha":0.0}}} \ No newline at end of file diff --git a/frontend/tests/fixtures/screens.rpy b/frontend/tests/fixtures/screens.rpy new file mode 100644 index 0000000..6f47d65 --- /dev/null +++ b/frontend/tests/fixtures/screens.rpy @@ -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 diff --git a/frontend/tests/screens.test.mjs b/frontend/tests/screens.test.mjs new file mode 100644 index 0000000..c6ccd62 --- /dev/null +++ b/frontend/tests/screens.test.mjs @@ -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(""); +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); +});