From ea03cebf6a6cd855d15e88e39586496ed43e473a Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:05:14 +0300 Subject: [PATCH] feat: ATL pictures/contains/function/time, colour matrices and more Python for Ren'Py ATL - Image blocks become timelines: picture steps with dissolve, parallel, contains, finite repeat, time, function f(trans, st, at), calls of other transforms with arguments, on hover/idle events for buttons. Animations stop when their element leaves the page - Pictures defined by an expression (get_face_path("base")) are drawn at show time - Purely graphical hints (shader, mesh, gl_*, u_*) are accepted and dropped Colour - matrixcolor and im.matrix.* as 5x5 matrices combined with `*`, applied through SVG filters; im.MatrixColor/Scale/Crop/Blur/Composite and Text/Image as values Python - match statement, re/copy/time/datetime/defaultdict/functools.partial, more str/dict methods, Character(...) created by code - narrator("..."), Character calls, renpy.say and renpy.show/hide with expressions in Python blocks become script nodes - Audit follows reachability: functions and classes nothing refers to (custom statement lexers, unused tools) no longer block an import Docs: README and ARCHITECTURE now describe a multi-engine library (GameMaker and QSP planned). Tests for the new runtime pieces. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 26 +++- backend/app/converter/atl.py | 75 ++++++++-- backend/app/converter/compatibility.py | 75 +++++++++- backend/app/converter/exprtokens.py | 28 ++++ backend/app/converter/pipeline.py | 19 +++ backend/app/converter/pyexpr.py | 48 ++++++- backend/app/converter/rpy_helpers.py | 2 +- backend/app/converter/rpy_parser.py | 164 ++++++++++++++++++++-- backend/app/converter/screens.py | 33 +---- backend/app/mods.py | 2 +- backend/tests/test_compatibility.py | 3 +- docs/ARCHITECTURE.md | 17 ++- frontend/src/player/atl.ts | 101 +++++++++++++- frontend/src/player/colormatrix.ts | 163 ++++++++++++++++++++++ frontend/src/player/engine.ts | 36 ++++- frontend/src/player/pymodules.ts | 182 +++++++++++++++++++++++++ frontend/src/player/python.ts | 47 ++++++- frontend/src/player/renderer.ts | 93 +++++++++++-- frontend/src/player/screens.ts | 109 ++++++++++++++- frontend/src/player/types.ts | 2 + frontend/tests/renpy-runtime.test.mjs | 132 ++++++++++++++++++ 21 files changed, 1264 insertions(+), 93 deletions(-) create mode 100644 backend/app/converter/exprtokens.py create mode 100644 frontend/src/player/colormatrix.ts create mode 100644 frontend/src/player/pymodules.ts create mode 100644 frontend/tests/renpy-runtime.test.mjs diff --git a/README.md b/README.md index 635bfee..f786fb5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,23 @@ -# PGames — локальная библиотека визуальных новелл +# PGames — локальная библиотека игр в браузере -Загружаете архив с игрой на Ren'Py или Unity-игру на Naninovel (включая -отдельный адаптер Tomie) — сервер распаковывает его, переводит -скрипты и ассеты в собственный формат **PGVN** (JSON-скрипты, WebP, Opus, WebM) -и удаляет исходники. Игры других движков отклоняются на входе. Играете в браузере во встроенном плеере. У каждой игры -может быть несколько версий (форков) от разных пользователей; версии делят -общие ассеты, поэтому форк почти не занимает места. +Домашний сервер для коллекции игр, которые играются прямо в браузере: визуальные новеллы, +игры-«песочницы» с открытым миром, текстовые квесты и другие. Вы загружаете архив или папку с игрой, +сервер распознаёт движок, переводит сценарии и ассеты в собственный формат **PGVN** (JSON-скрипты, +WebP, Opus, WebM) и удаляет исходники; играть можно во встроенном плеере, без установки. +У каждой игры может быть несколько версий (форков) от разных пользователей; версии делят общие +ассеты, поэтому форк почти не занимает места. Есть редактор модов и переводов, ревизии и слияние. + +Каждый движок и каждая особенная игра подключаются профилем (см. [docs/GAME_PROFILES.md](docs/GAME_PROFILES.md)): + +| Движок | Состояние | +| --- | --- | +| Ren'Py | основной: сценарии, Python-песочница, ATL, экраны | +| Unity + Naninovel (в том числе Tomie) | импорт скомпилированных сценариев | +| GameMaker (песочницы вроде FF692) | планируется: загрузчик `data.win` и виртуальная машина GML | +| QSP (Quest Soft Player) | планируется: текстовые квесты `.qsp` | +| Unity (без Naninovel), Godot, RPG Maker | пока отклоняются при загрузке | + +Игры других движков отклоняются на входе: неподдерживаемое никогда не «импортируется наполовину». Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py index be225b7..dc74e1e 100644 --- a/backend/app/converter/atl.py +++ b/backend/app/converter/atl.py @@ -10,6 +10,10 @@ A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps: * ``{"op": "block", "block": block}`` * ``{"op": "on", "events": [...], "block": block}`` * ``{"op": "use", "name": transform}`` +* ``{"op": "image", "img": ImageDef, "with": transition | None}`` – show another picture +* ``{"op": "contains", "img": {"timeline": block}}`` – a child picture with its own timeline +* ``{"op": "function", "f": expr}`` – call ``f(trans, st, at)`` every frame (Python decides the motion) +* ``{"op": "time", "t": seconds}`` – wait until this much time has passed since the block began 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):``). @@ -23,16 +27,22 @@ from collections.abc import Callable from typing import Any from app.converter import pyexpr +from app.converter.exprtokens import read_value from app.converter.rpy_helpers import POSITION_PROPS from app.converter.rpy_lexer import Line Block = dict[str, Any] +GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\))$", re.S) # Warpers differ only in easing shape; the player implements these four curves and # maps the named variants (easein_quad, ease_back, ...) onto the closest one. WARPER = re.compile(r"^(linear|ease|easein|easeout|easeinout)(_(quad|cubic|quart|quint|expo|circ|back|bounce|elastic|sine))?$") IGNORED = {"subpixel", "animation"} # no visible effect in the browser -EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default"} +# Purely graphical GPU hints; dropping them changes how a picture is drawn, never what happens +COSMETIC = ("shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate", + "zrotate", "zpos", "zzoom", "delay", "events", "fit", "nearest", "fps", "show_cancels_hide") +EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default", "hover", "idle", + "selected_hover", "selected_idle", "insensitive", "selected_insensitive", "selected"} def tokens(text: str) -> list[str]: @@ -65,9 +75,12 @@ def tokens(text: str) -> list[str]: class AtlParser: def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None], - on_use: Callable[[str], None] = lambda name: None): + on_use: Callable[[str], None] = lambda name: None, + image: Callable[[Line], tuple[dict, Any] | None] | None = None): self.warn = warn self.on_use = on_use + # a line that is a picture ("a.png" with dissolve) -> (ImageDef, transition) | None + self.image = image # name -> props when `name` is a known static transform (inlined), else None self.static_transform = is_static @@ -101,8 +114,12 @@ class AtlParser: i = 0 while i < len(parts): key = parts[i] - if key in IGNORED: - i += 2 + glued = GLUED.match(key) # decompiled `anchor(0.5, 0.5)` + if glued and (glued[1] in POSITION_PROPS or glued[1] in IGNORED or glued[1].startswith(COSMETIC)): + parts = [*parts[:i], glued[1], glued[2], *parts[i + 1:]] + key = glued[1] + if key in IGNORED or key.startswith(COSMETIC): + _, i = read_value(parts, i + 1) if i + 1 < len(parts) else ("", i + 1) continue if key not in POSITION_PROPS: self.warn(f"Не поддерживается ATL: свойство {key}", line) @@ -110,11 +127,14 @@ class AtlParser: if i + 1 >= len(parts): self.warn(f"Не поддерживается ATL: у {key} нет значения", line) return out - value = self.value(parts[i + 1], line) + source, i = read_value(parts, i + 1) + value = self.value(source, line) if value is None: return out + if key == "xycenter" and isinstance(value, (list, tuple)) and len(value) == 2: + out["xcenter"], out["ycenter"] = value + continue out[key] = list(value) if isinstance(value, tuple) else value - i += 2 return out # --- block ---------------------------------------------------------------------- @@ -131,7 +151,10 @@ class AtlParser: continue first = parts[0] i += 1 - if first == "repeat": + picture = self.image(line) if self.image and not line.block else None + if picture: + steps.append({"op": "image", "img": picture[0], "with": picture[1]}) + elif 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 = repeat if isinstance(repeat, dict) else int(repeat) @@ -145,10 +168,12 @@ class AtlParser: self.move(first, parts, line, steps) elif first in IGNORED: continue - elif first in POSITION_PROPS: + elif first in POSITION_PROPS or ((g := GLUED.match(first)) and g[1] in POSITION_PROPS): props = self.props(parts, line) if props: steps.append({"op": "set", "props": props}) + elif len(parts) == 1 and (called := self.transform_call(first, line)): + steps.append(called) elif len(parts) == 1 and re.fullmatch(r"[A-Za-z_]\w*", first): self.on_use(first) known = self.static_transform(first) @@ -157,6 +182,19 @@ class AtlParser: self.warn(f"Не поддерживается ATL: {first}", line) return {"steps": steps, "repeat": repeat} + def transform_call(self, source: str, line: Line) -> dict[str, Any] | None: + """`other_transform(args)` inside a transform: run it with these arguments.""" + if not GLUED.match(source) or source.split("(")[0] in POSITION_PROPS: + return None + try: + call = pyexpr.parse_expr(source) + except pyexpr.Unsupported: + return None + if call["k"] != "call" or call["f"]["k"] != "name": + return None + self.on_use(call["f"]["id"]) + return {"op": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"]} + def move(self, warp: str, parts: list[str], line: Line, steps: list) -> None: base = WARPER.match(warp).group(1) # type: ignore[union-attr] if len(parts) < 2: @@ -173,8 +211,25 @@ class AtlParser: def compound(self, first: str, parts: list[str], lines: list[Line], index: int, steps: list) -> int: """Handle parallel/choice/on groups. Returns the index of the next unread line.""" line = lines[index] - if first in ("time", "contains", "function"): - self.warn(f"Не поддерживается ATL: {first}", line) + if first == "function": + expr = self.value(" ".join(parts[1:]), line) if len(parts) > 1 else None + if isinstance(expr, dict): + steps.append({"op": "function", "f": expr["e"]}) + else: + self.warn("Не поддерживается ATL: function", line) + return index + 1 + if first == "time": + seconds = self.number(parts[1], line) if len(parts) > 1 and not line.block else None + if seconds is None: + self.warn("Не поддерживается ATL: time", line) + else: + steps.append({"op": "time", "t": seconds}) + return index + 1 + if first == "contains": + if len(parts) == 1 and line.block: + steps.append({"op": "contains", "img": {"timeline": self.block(line.block)}}) + else: + self.warn("Не поддерживается ATL: contains", line) return index + 1 if first == "block": steps.append({"op": "block", "block": self.block(line.block)}) diff --git a/backend/app/converter/compatibility.py b/backend/app/converter/compatibility.py index 4f832c9..c924ee2 100644 --- a/backend/app/converter/compatibility.py +++ b/backend/app/converter/compatibility.py @@ -111,11 +111,27 @@ SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullActi 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()) +ComposeTransition With Text SetLocalVariable QuickSave QuickLoad AlphaMask Fixed At +TintMatrix BrightnessMatrix SaturationMatrix ContrastMatrix InvertMatrix OpacityMatrix HueMatrix +ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split()) +SCREEN_CALLS |= {"im." + name for name in ( + "MatrixColor Scale FactorScale Flip Crop Grayscale Sepia Alpha Blur Composite Tile Rotozoom " + "matrix.brightness matrix.contrast matrix.saturation matrix.tint matrix.identity matrix.invert " + "matrix.opacity matrix.hue matrix.colorize").split()} +SCREEN_CALLS |= {"Image", "re.sub", "re.match", "re.search", "re.findall", "re.split", "re.escape", + "copy.copy", "copy.deepcopy", "time.time", "time.strftime", "time.sleep", + "datetime.now", "datetime.datetime.now", "datetime.today", "datetime.date.today", + "collections.defaultdict", "collections.OrderedDict", "renpy.partial", "renpy.loadable", + "renpy.is_init_phase", "renpy.call_stack_depth", "renpy.const", "renpy.screenshot", + "renpy.Keymap", "renpy.register_shader", "renpy.register_statement", "renpy.redraw", + "renpy.stop_predict", "renpy.map_event", "renpy.count_displayables_in_layer", + "renpy.displayable", "renpy.game.context", "renpy.unlink_save", "json.dumps", "json.loads", + "_window_hide", "_window_show", "functools.partial"} +CALLS |= SCREEN_CALLS CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "renpy.seen_image", "renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction", "renpy.get_screen", "renpy.call_screen"} -METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha".split()) +METHODS = set("append extend insert pop remove index count reverse sort clear copy add discard get keys values items setdefault update lower upper capitalize title strip lstrip rstrip startswith endswith replace split join format find isdigit isalpha zfill ljust rjust center partition rpartition rsplit splitlines isupper islower isspace isalnum swapcase rfind has_key iteritems iterkeys itervalues strftime isoformat weekday group groups groupdict start end".split()) def _bound_names(root) -> tuple[set[str], set[str]]: @@ -197,6 +213,59 @@ def mark_screen_calls(data) -> None: [data.screens[n] for n in data.screen_used if n in data.screens]]) +IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"} + + +def reachable_init_code(data, used_screens) -> list: + """The part of `init python` that can run: startup statements plus every function, class and + method that something reachable refers to (by name or as an attribute). + + Definitions nobody refers to - lexers of custom statements, helpers of unused tools - are left + out of the audit; if the game ever calls one, the player stops with an explicit error. + """ + roots: list = [] + definitions: dict[str, list] = {} + + def collect(statements: list) -> None: + for statement in statements: + op = statement.get("op") + if op == "def": + definitions.setdefault(statement["name"], []).append(statement) + elif op == "class": + definitions.setdefault(statement["name"], []).append(statement) + collect([m for m in statement["body"] if m.get("op") == "def"]) + else: + roots.append(statement) + + collect(data.init_code) + result: list = list(roots) + taken: set[int] = set() + queue: list = [roots, list(data.modules.values()), data.defaults, data.images, list(used_screens.values())] + while queue: + fragment = queue.pop() + stack = [fragment] + while stack: + value = stack.pop() + if isinstance(value, list): + stack.extend(value) + elif isinstance(value, dict): + name = value.get("id") if value.get("k") == "name" else value.get("a") if value.get("k") == "attr" else None + for node in definitions.get(name, ()) if name else (): + if id(node) in taken: + continue + taken.add(id(node)) + if node["op"] == "class": + # class-level assignments and the methods Python calls implicitly + own = [m for m in node["body"] if m.get("op") != "def" or m["name"] in IMPLICIT_METHODS] + result.extend(own) + queue.append(own) + else: + result.append(node) + queue.append(node) + stack.extend(value.values()) + return result + + def audit_ir(data) -> None: """Reject calls which the browser would otherwise silently replace by None.""" mark_screen_calls(data) @@ -241,5 +310,5 @@ def audit_ir(data) -> None: 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(reachable_init_code(data, used_screens), "init") visit(data.images, "image") diff --git a/backend/app/converter/exprtokens.py b/backend/app/converter/exprtokens.py new file mode 100644 index 0000000..93ddaff --- /dev/null +++ b/backend/app/converter/exprtokens.py @@ -0,0 +1,28 @@ +"""Splitting Ren'Py "simple expression" values out of a line (screens and ATL).""" +import re + +OPERATORS = {"+", "-", "*", "/", "//", "%", "**", "==", "!=", "<", ">", "<=", ">=", "and", "or", "in", "is", + "if", "else", "not"} + + +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 + 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 diff --git a/backend/app/converter/pipeline.py b/backend/app/converter/pipeline.py index e91b2b1..c7e7422 100644 --- a/backend/app/converter/pipeline.py +++ b/backend/app/converter/pipeline.py @@ -344,6 +344,8 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st if "frames" in img: img["frames"] = [{**f, "img": _resolve_image(f["img"], aliases, missing)} for f in img["frames"]] + if "timeline" in img: + img["timeline"] = _resolve_timeline(img["timeline"], aliases, missing) if "layered" in img: layers = [] for layer in img["layered"]: @@ -359,6 +361,23 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st return img +def _resolve_timeline(block: dict[str, Any], aliases: dict[str, str], missing: set[str]) -> dict[str, Any]: + """Resolve the pictures an ATL timeline shows.""" + def step(s: dict[str, Any]) -> dict[str, Any]: + s = dict(s) + if s["op"] in ("image", "contains"): + s["img"] = _resolve_image(s["img"], aliases, missing) + elif s["op"] == "parallel": + s["blocks"] = [_resolve_timeline(b, aliases, missing) for b in s["blocks"]] + elif s["op"] == "choice": + s["blocks"] = [{**b, "block": _resolve_timeline(b["block"], aliases, missing)} for b in s["blocks"]] + elif s["op"] in ("block", "on"): + s["block"] = _resolve_timeline(s["block"], aliases, missing) + return s + + return {**block, "steps": [step(s) for s in block["steps"]]} + + def anchors(labels: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: """Positions of `label` markers (nested labels, named menus) inside label bodies.""" found: dict[str, dict[str, Any]] = {} diff --git a/backend/app/converter/pyexpr.py b/backend/app/converter/pyexpr.py index e126694..153621c 100644 --- a/backend/app/converter/pyexpr.py +++ b/backend/app/converter/pyexpr.py @@ -24,7 +24,8 @@ CMP_OPS = { UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"} # Modules whose import is harmless: the player provides the functions it supports. -KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime"} +KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections", + "itertools", "string", "functools", "operator", "json"} class Unsupported(Exception): @@ -177,6 +178,8 @@ def stmt(node: ast.stmt) -> list[Stmt]: case ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=final): return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers], "else": stmts(orelse), "final": stmts(final)}] + case ast.Match(subject=subject, cases=cases): + return match_statement(subject, cases) case ast.Raise(exc=exc): return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped case ast.Assert(test=test, msg=msg): @@ -273,3 +276,46 @@ def handler(node: ast.ExceptHandler) -> dict[str, Any]: """``types`` is None for a bare ``except:``; ``name`` binds the exception.""" return {"types": None if node.type is None else expr(node.type), "name": node.name, "body": stmts(node.body)} + + +_match_counter = 0 + + +def match_statement(subject: ast.expr, cases: list[ast.match_case]) -> list[Stmt]: + """`match` becomes an if/elif chain over a temporary holding the subject.""" + global _match_counter + _match_counter += 1 + temp = f"_pgm_{_match_counter}" + name = {"k": "name", "id": temp} + + def pattern(p: ast.pattern) -> tuple[Expr, list[Stmt]]: + match p: + case ast.MatchValue(value=value): + return {"k": "cmp", "l": name, "ops": ["=="], "r": [expr(value)]}, [] + case ast.MatchSingleton(value=value): + return {"k": "cmp", "l": name, "ops": ["is"], "r": [const(value)]}, [] + case ast.MatchAs(pattern=None, name=None): + return const(True), [] + case ast.MatchAs(pattern=None, name=capture): + return const(True), [{"op": "set", "t": {"k": "name", "id": capture}, "v": name}] + case ast.MatchAs(pattern=inner, name=capture): + cond, binds = pattern(inner) + if capture: + binds = [*binds, {"op": "set", "t": {"k": "name", "id": capture}, "v": name}] + return cond, binds + case ast.MatchOr(patterns=alternatives): + parts = [pattern(a) for a in alternatives] + if any(binds for _, binds in parts): + raise Unsupported("match: захват внутри альтернативы") + return {"k": "bool", "op": "or", "v": [c for c, _ in parts]}, [] + raise Unsupported(f"match: шаблон {type(p).__name__}") + + chain: list[Stmt] = [] + for case in reversed(cases): + cond, binds = pattern(case.pattern) + if case.guard is not None: + if binds: + raise Unsupported("match: захват вместе с условием guard") + cond = {"k": "bool", "op": "and", "v": [cond, expr(case.guard)]} + chain = [{"op": "if", "c": cond, "then": [*binds, *stmts(case.body)], "else": chain}] + return [{"op": "set", "t": name, "v": expr(subject)}, *chain] diff --git a/backend/app/converter/rpy_helpers.py b/backend/app/converter/rpy_helpers.py index f22c606..fce1b64 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", "blur"} + "align", "pos", "anchor", "xysize", "size", "blur", "matrixcolor", "xycenter"} _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 60e8729..f5b2a8d 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -297,12 +297,25 @@ class RpyParser: return c.string_raw() is not None or c.peek('"') or c.peek("'") def dollar(self, code: str, line: Line) -> list[Node]: - stmts = self.python(code, line) - if len(stmts) == 1 and stmts[0]["op"] == "eval": - special = self.renpy_call(stmts[0]["e"], line) - if special is not None: - return special - return [{"op": "exec", "code": stmts}] if stmts else [] + return self.python_nodes(self.python(code, line), line) + + def python_nodes(self, stmts: list[Stmt], line: Line) -> list[Node]: + """Statements become one `exec` node, except calls that are really script commands + (renpy.show, renpy.say, narrator("...")): those become the matching nodes.""" + nodes: list[Node] = [] + pending: list[Stmt] = [] + for statement in stmts: + special = self.renpy_call(statement["e"], line) if statement.get("op") == "eval" else None + if special is None: + pending.append(statement) + continue + if pending: + nodes.append({"op": "exec", "code": pending}) + pending = [] + nodes.extend(special) + if pending: + nodes.append({"op": "exec", "code": pending}) + return nodes def renpy_call(self, e: Expr, line: Line) -> list[Node] | None: """Turn common renpy.* calls into native script nodes.""" @@ -332,17 +345,66 @@ class RpyParser: if "channel" in kw and kw["channel"].get("k") == "const": ch = kw["channel"]["v"] return [{"op": "stop", "ch": ch, "fadeout": _const(kw.get("fadeout"))}] - if fname == "renpy.show" and args and args[0].get("k") == "const": - return [{"op": "show", "img": {"name": str(args[0]["v"]).split()}, "at": [], - "with": None}] - if fname == "renpy.hide" and args and args[0].get("k") == "const": - return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}] + if fname == "renpy.show" and args: + at = self.at_list(kw.get("at_list"), line) + if at is None: + return None + node: Node = {"op": "show", "at": at, "with": None, + "img": {"name": str(args[0]["v"]).split()} if args[0].get("k") == "const" + else {"expr": args[0]}} + if "zorder" in kw: + node["zorder"] = kw["zorder"] + return [node] + if fname == "renpy.hide" and args: + if args[0].get("k") == "const": + return [{"op": "hide", "tag": str(args[0]["v"]).split()[0]}] + return [{"op": "hide", "tag": "", "tagExpr": args[0]}] + if fname == "renpy.say" and len(args) >= 2: + return [self.say_node(args[0], args[1])] + if fname == "narrator" or fname in self.d.characters: + if len(args) == 1 and set(kw) <= {"interact"}: + who = {"k": "const", "v": None} if fname == "narrator" else {"k": "name", "id": fname} + node = self.say_node(who, args[0]) + if "interact" in kw and kw["interact"] == {"k": "const", "v": False}: + node["interact"] = False + return [node] if fname == "renpy.scene": return [{"op": "scene", "img": None, "at": [], "with": None}] if fname == "renpy.with_statement" and args: return [{"op": "with", "t": self.transition_expr(args[0])}] return None + def at_list(self, e: Expr | None, line: Line) -> list[dict[str, Any]] | None: + """``at_list=[a, b]`` of renpy.show: names of transforms, or None when not understood.""" + if e is None: + return [] + if e.get("k") not in ("list", "tuple"): + return None + out = [] + for item in e["v"]: + if item.get("k") != "name": + return None + out.append(self.transform_ref(item["id"], line)) + return out + + def say_node(self, who: Expr, what: Expr) -> Node: + """A say command from Python: literal text stays translatable, anything else is evaluated.""" + node: Node = {"op": "say", "what": ""} + if (what.get("k") == "call" and what["f"].get("k") == "name" and what["f"]["id"] in ("_", "__") + and len(what["a"]) == 1): + what = what["a"][0] # _("text"): the marker only asks for translation, which says do anyway + if what.get("k") == "const" and isinstance(what["v"], str): + node["what"] = what["v"] + else: + node["whatExpr"] = what + if who.get("k") == "const" and who["v"] is None: + pass + elif who.get("k") == "name" and (who["id"] in self.d.characters or who["id"] == "narrator"): + node["who"] = who["id"] + else: + node["whoExpr"] = who + return node + # init / python ---------------------------------------------------------------- def st_init(self, line: Line) -> list[Node]: @@ -369,8 +431,7 @@ class RpyParser: if c.keyword("early"): self.init_python(line.block_source(), line) return [] - code = self.python(line.block_source(), line) - return [{"op": "exec", "code": code}] if code else [] + return self.python_nodes(self.python(line.block_source(), line), line) def st_rpy(self, line: Line) -> list[Node]: return [] @@ -616,6 +677,38 @@ class RpyParser: MOTION_WORDS = {"linear", "ease", "easein", "easeout", "easeinout", "parallel", "choice", "repeat", "block", "on"} + TIMELINE_WORDS = {"function", "time", "contains", "animation", "parallel", "choice", "block", "on"} + + def atl_image_line(self, line: Line) -> tuple[dict, Any] | None: + """A picture inside an ATL block: `"a.png" with dissolve` -> (ImageDef, transition).""" + if not self.displayable_line(line): + return None + match = re.search(r"\s+with\s+", line.text) + if match: + picture, transition = line.text[:match.start()].strip(), line.text[match.end():].strip() + else: + picture, transition = line.text.strip(), "" + expr = self.expr(picture, line) + image = self.displayable_expr(expr, line) if expr else None + if image is None: + return None + return image, (self.transition(transition, line) if transition.strip() else None) + + def single_motion(self, lines: list[Line]) -> bool: + """One picture plus plain linear/ease motion, handled by the lighter `at` path.""" + shown = [child for child in lines if self.displayable_line(child)] + return len(shown) == 1 and not any( + (Cursor(child.text).peek_word() or "") in self.TIMELINE_WORDS for child in lines) + + def needs_timeline(self, lines: list[Line]) -> bool: + """Whether an `image` block is more than a static picture or a plain slideshow.""" + for child in lines: + word = Cursor(child.text).peek_word() or "" + if word in self.TIMELINE_WORDS or WARPER.match(word): + return True + if word == "repeat" and child.text.strip() != "repeat": + return True + return False def displayable_line(self, child: Line) -> bool: if self.cursor(child).string() is not None: @@ -623,9 +716,51 @@ class RpyParser: m = _CALL_LINE.match(child.text) return bool(m and m.group(1) in _DISPLAYABLE_CALLS) + def expression_picture(self, line: Line) -> dict[str, Any] | None: + """``image x:`` whose first line is a call computing the picture (``get_face("base")``) followed by + transform lines: the picture is evaluated when the image is shown, the transforms are ATL.""" + if not line.block: + return None + first = line.block[0] + match = _CALL_LINE.match(first.text) + word = Cursor(first.text).peek_word() or "" + if (not match or self.displayable_line(first) or match.group(1) in self.d.transforms + or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS): + return None + rest = line.block[1:] + expr = self.expr(first.text, first) + if expr is None or not rest: + return None + issues: list[tuple[str, str]] = [] + props, animation = self.atl_spec(rest, issues.append, set()) + if issues or not animation: + return None + self.d.warn("Изображение задано выражением", self.where(first), blocking=False) + return {"expr": expr, "at": [{"props": props, "atl": animation}]} + def atl_image(self, line: Line) -> dict[str, Any] | None: + if (picture := self.expression_picture(line)) is not None: + return picture + if self.needs_timeline(line.block) and not self.single_motion(line.block): + props, timeline = self.atl_spec(line.block) + if timeline is None: + return None + return {"timeline": timeline} shown = [child for child in line.block if self.displayable_line(child)] rest = [child for child in line.block if not self.displayable_line(child)] + if len(shown) == 1 and rest: + # a picture with computed properties (matrixcolor OpacityMatrix(...)): try the timeline path quietly + issues: list[tuple[str, str]] = [] + props, animation = self.atl_spec(rest, issues.append, set()) + if animation and not issues: + first = self.cursor(shown[0]).string() + if first is not None: + img = self.displayable_expr(const(first), shown[0]) + else: + expr = self.expr(shown[0].text.split(" with ")[0], shown[0]) + img = self.displayable_expr(expr, shown[0]) if expr else None + if img: + return {**img, "at": [*img.get("at", []), {"props": props, "atl": animation}]} if len(shown) == 1 and any( (word := Cursor(child.text).peek_word()) in self.MOTION_WORDS or WARPER.match(word or "") for child in rest): @@ -881,7 +1016,8 @@ class RpyParser: uses.add(name) parser = AtlParser(warn, lambda name: self.d.transforms.get(name) if name not in self.d.atl - and name in self.d.transforms else None, on_use) + and name in self.d.transforms else None, on_use, + lambda line: self.atl_image_line(line)) block = parser.block(lines) static = static_props(block) if static is not None: diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index 5da1d52..6f8e222 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -21,6 +21,7 @@ from typing import Any from app.converter import pyexpr from app.converter.atl import WARPER +from app.converter.exprtokens import read_value from app.converter.pyexpr import Unsupported from app.converter.rpy_lexer import Line @@ -41,7 +42,7 @@ 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()) +xtile ytile matrixcolor blur""".split()) TEXT = set("""color size font bold italic underline strikethrough outlines text_align textalign line_spacing kerning layout first_indent min_width antialias xalign yalign justify hyperlink_functions slow_cps""".split()) BEHAVIOUR = set("""action alternate hovered unhovered sensitive tooltip focus_mask mouse hover_sound @@ -58,8 +59,6 @@ STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", " 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) @@ -110,29 +109,6 @@ def tokens(text: str) -> list[str]: 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): @@ -191,7 +167,8 @@ class ScreenParser: i = 0 while i < len(lines): line = lines[i] - words = tokens(line.text.rstrip(":").strip() if line.block else line.text.strip()) + text = line.text.rstrip(":").strip() if line.block else line.text.strip() + words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text)) i += 1 if not words: continue @@ -199,7 +176,7 @@ class ScreenParser: if first in ("if", "showif"): branches = [self.branch(first, words, line)] while i < len(lines): - nxt = tokens(lines[i].text.rstrip(":").strip()) + nxt = tokens(re.sub(r"^(elif|else)\(", r"\1 (", lines[i].text.rstrip(":").strip())) if not nxt or nxt[0] not in ("elif", "else"): break branches.append(self.branch(nxt[0], nxt, lines[i])) diff --git a/backend/app/mods.py b/backend/app/mods.py index c6e012c..2a31e48 100644 --- a/backend/app/mods.py +++ b/backend/app/mods.py @@ -75,7 +75,7 @@ def validate(doc, manifest): raise ValueError("Некорректный оператор") identity(n) op = n["op"] - for key in ("expr", "c", "d"): + for key in ("expr", "c", "d", "whatExpr", "whoExpr", "tagExpr"): if isinstance(n.get(key), dict): expression(n[key]) if op in ("call_screen", "show_screen"): diff --git a/backend/tests/test_compatibility.py b/backend/tests/test_compatibility.py index a0c1393..af880b1 100644 --- a/backend/tests/test_compatibility.py +++ b/backend/tests/test_compatibility.py @@ -102,8 +102,7 @@ def test_renpy_atl_animation_becomes_a_timeline_not_a_static_pose(): def test_renpy_atl_constructs_without_a_runtime_are_still_rejected(): - for body in ("function slide", "time 1.0:\n xalign 1.0", "matrixcolor TintMatrix('#f00')", - "contains:\n 'a.png'"): + for body in ("time 1.0:\n xalign 1.0", "knot (0, 0)", "circles 0.5", "contains 'a.png'"): unused = script(f'transform odd:\n {body}\nlabel start:\n "Hi"') require_supported_scripts(unused) # nothing shows it, so it cannot break the game used = script(f'transform odd:\n {body}\nlabel start:\n show eileen at odd\n "Hi"') diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e27481e..e160fdb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,9 +1,10 @@ # pgames-manager — архитектура -Локальная библиотека визуальных новелл (сначала Ren'Py). Игры не запускаются -оригинальным движком: архив разбирается на сервере, скрипт и ассеты -перекладываются в собственный формат **PGVN**, а играется копия в нашем -веб-плеере. +Локальная библиотека игр, которые играются в браузере: визуальные новеллы, «песочницы», текстовые +квесты (см. [README](../README.md)). Игры не запускаются оригинальным движком: архив разбирается на +сервере, скрипт и ассеты перекладываются в собственный формат **PGVN**, а играется копия в нашем +веб-плеере. Для каждого движка и для особенных игр есть профиль ([GAME_PROFILES.md](GAME_PROFILES.md)), +у игр с собственным устройством — собственная среда исполнения (`game.runtime`). ## Развёртывание @@ -145,8 +146,12 @@ LOCKED`), Redis не нужен. Планы: -* ATL `function`, `matrixcolor`, шейдеры и `im.*` (экраны и ATL с движением уже есть: +* шейдеры и детали `im.*` (экраны, ATL, `matrixcolor` уже есть: [SCREENS.md](SCREENS.md), `backend/app/converter/atl.py`, `frontend/src/player/atl.ts`); -* другие движки (TyranoBuilder, RPG Maker MV) через тот же IR; +* движки без своего профиля: **GameMaker** (`data.win`, виртуальная машина GML — отдельный проект), + **QSP** (текстовые квесты `.qsp`: свой формат, локации, переменные, действия и вставки HTML), + затем TyranoBuilder, RPG Maker MV; +* Ren'Py: код Python, который ждёт игрока (`renpy.input`, `narrator()`, `renpy.say` внутри функций и + циклов) — для него нужен интерпретатор на генераторах; * дальнейшее дробление `engine.ts` на модули (рендер сцены, UI, сохранения); * типизация backend под `mypy` (сейчас проверка справочная). diff --git a/frontend/src/player/atl.ts b/frontend/src/player/atl.ts index 270ed9b..54b42e3 100644 --- a/frontend/src/player/atl.ts +++ b/frontend/src/player/atl.ts @@ -1,5 +1,7 @@ // Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image. +import { ColorMatrix } from "./colormatrix"; + export type AtlProps = Record; /** A literal, or an expression evaluated when the timeline runs (`{ e: expr }`). */ @@ -20,7 +22,11 @@ export type AtlStep = | { op: "choice"; blocks: { w: AtlValue; block: AtlBlock }[] } | { op: "block"; block: AtlBlock } | { op: "on"; events: string[]; block: AtlBlock } - | { op: "use"; name: string }; + | { op: "use"; name: string; args?: any[]; kw?: Record } + | { op: "image"; img: any; with: any } + | { op: "contains"; img: any } + | { op: "function"; f: any } + | { op: "time"; t: AtlValue }; export interface AtlHost { /** Timeline of a named animated transform. */ @@ -31,6 +37,10 @@ export interface AtlHost { apply(props: AtlProps): void; /** Evaluate a run-time value with the transform's parameters in scope. */ eval?(expr: any, vars: Map): any; + /** `"other.png" with dissolve`: show another picture in the animated element. */ + image?(img: any, transition: any): void; + /** `contains:`: add a child picture that runs its own timeline. */ + contains?(img: any): void; } /** Bind `name(args)` of an `at` clause to the transform's parameters. `ev` evaluates in the caller's scope. */ @@ -69,8 +79,13 @@ function nextFrame() { export class AtlRunner { private token = 0; + /** When set, the timeline stops by itself once this element has left the document. */ + watch: HTMLElement | null = null; + private attached = false; + constructor(private host: AtlHost, private props: AtlProps, private vars: Map = new Map()) {} + /** Resolve `{ e: expr }` values (also inside tuples). */ private val(v: AtlValue | undefined): any { if (Array.isArray(v)) return v.map((x) => this.val(x)); @@ -138,17 +153,41 @@ export class AtlRunner { private alive(token: number) { if (token !== this.token) throw new Stopped(); + if (this.watch) { + if (this.watch.isConnected) this.attached = true; + else if (this.attached) { + this.token++; + throw new Stopped(); + } + } + } + + /** Deliver a button/interaction event (`on hover:` ...) to the handlers of `block`. */ + event(block: AtlBlock, name: string) { + const handlers = this.handlers(block, new Set([name])); + if (!handlers.length) return; + const token = ++this.token; + void (async () => { + try { + for (const handler of handlers) await this.run(handler, token, 0, name); + } catch (err) { + if (!(err instanceof Stopped)) throw err; + } + })(); } private apply() { this.host.apply(this.props); } + private blockStart = 0; + private async run(block: AtlBlock, token: number, depth: number, event: string): Promise { if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность"); let loops = 0; for (;;) { const startedAt = performance.now(); + this.blockStart = startedAt; let waited = false; for (let i = 0; i < block.steps.length; i++) { this.alive(token); @@ -203,8 +242,18 @@ export class AtlRunner { return true; case "use": { const named = this.host.block(step.name); - if (named) await this.run(named, token, depth + 1, event); - else { + if (named) { + // `other(args)`: the callee runs with its own parameters, on the same properties + const callerVars = this.vars; + if (step.args || step.kw || named.params) { + this.vars = bindArguments(named, step, (e) => (this.host.eval ? this.host.eval(e, callerVars) : null)); + } + try { + await this.run(named, token, depth + 1, event); + } finally { + this.vars = callerVars; + } + } else { const props = this.host.props(step.name); if (props) { Object.assign(this.props, props); @@ -213,11 +262,53 @@ export class AtlRunner { } return true; } + case "image": + this.host.image?.(step.img, step.with); + return false; + case "contains": + this.host.contains?.(step.img); + return false; + case "time": { + const target = this.blockStart + this.seconds(step.t) * 1000; + while (performance.now() < target) { + await nextFrame(); + this.alive(token); + } + return true; + } + case "function": + await this.callFunction(step.f, token); + return true; case "on": return false; } } + /** `function f`: Python moves the element. f(trans, st, at) returns the delay until the next call, or None. */ + private async callFunction(expr: any, token: number) { + const f = this.host.eval ? this.host.eval(expr, this.vars) : null; + if (typeof f !== "function") return; + const props = this.props; + const trans = new Proxy({}, { + get: (_t, key: string) => props[key] ?? this.current(key), + set: (_t, key: string, value) => { + props[key] = value; + return true; + }, + }); + const started = performance.now(); + for (;;) { + this.alive(token); + const elapsed = (performance.now() - started) / 1000; + const next = f(trans, elapsed, elapsed); + this.apply(); + if (next === null || next === undefined) return; + const delay = Number(next); + if (!Number.isFinite(delay)) return; + await this.sleep(Math.max(delay, 0.016), token); + } + } + private async sleep(seconds: number, token: number) { const end = performance.now() + seconds * 1000; while (performance.now() < end) { @@ -265,6 +356,10 @@ export class AtlRunner { function mix(a: any, b: any, k: number): any { if (typeof a === "number" && typeof b === "number") return a + (b - a) * k; + if (a instanceof ColorMatrix && b instanceof ColorMatrix) { + return new ColorMatrix(a.rows.map((row, i) => row.map((v, j) => v + (b.rows[i][j] - v) * k) as any)); + } + if (b instanceof ColorMatrix && (a === undefined || a === null || a === 0)) return mix(ColorMatrix.identity(), b, k); if (Array.isArray(a) && Array.isArray(b) && a.length === b.length) return a.map((x, i) => mix(x, b[i], k)); return k >= 1 ? b : a; } diff --git a/frontend/src/player/colormatrix.ts b/frontend/src/player/colormatrix.ts new file mode 100644 index 0000000..6b92d48 --- /dev/null +++ b/frontend/src/player/colormatrix.ts @@ -0,0 +1,163 @@ +// Colour matrices (Ren'Py `matrixcolor` and the older `im.matrix`) as 5x5 matrices over (r, g, b, a, 1), +// applied to pictures through an SVG feColorMatrix filter. + +export type Row = [number, number, number, number, number]; + +const LUMA: [number, number, number] = [0.2126, 0.7152, 0.0722]; + +export class ColorMatrix { + /** Rows r, g, b, a and the constant row [0, 0, 0, 0, 1]. */ + constructor(public rows: Row[]) {} + + static identity() { + return new ColorMatrix([[1, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, 1, 0, 0], [0, 0, 0, 1, 0], [0, 0, 0, 0, 1]]); + } + + /** `a * b` applies b first, then a. */ + times(other: ColorMatrix): ColorMatrix { + const out: Row[] = []; + for (let i = 0; i < 5; i++) { + const row = [0, 0, 0, 0, 0] as Row; + for (let j = 0; j < 5; j++) { + let sum = 0; + for (let k = 0; k < 5; k++) sum += this.rows[i][k] * other.rows[k][j]; + row[j] = sum; + } + out.push(row); + } + return new ColorMatrix(out); + } + + /** Lets the Python sandbox multiply matrices with `*`. */ + __binop__(op: string, other: any) { + if (op === "*" && other instanceof ColorMatrix) return this.times(other); + throw new TypeError("Матрицы цвета можно только перемножать"); + } + + /** The 20 numbers of an feColorMatrix. */ + values(): string { + return this.rows.slice(0, 4).map((row) => row.map((v) => +v.toFixed(5)).join(" ")).join(" "); + } + + toJSON() { + return undefined; // never part of a save + } +} + +function diag(r: number, g: number, b: number, a = 1) { + const m = ColorMatrix.identity(); + m.rows[0][0] = r; + m.rows[1][1] = g; + m.rows[2][2] = b; + m.rows[3][3] = a; + return m; +} + +/** "#rgb", "#rrggbb", "#rrggbbaa" or a (r, g, b[, a]) tuple of 0-255 / 0-1 numbers -> [r, g, b, a] in 0..1. */ +export function parseColor(value: any): [number, number, number, number] { + if (typeof value === "string") { + let hex = value.replace("#", ""); + if (hex.length === 3 || hex.length === 4) hex = [...hex].map((c) => c + c).join(""); + const n = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255; + return [n(0) || 0, n(2) || 0, n(4) || 0, hex.length >= 8 ? n(6) : 1]; + } + if (Array.isArray(value)) { + const scale = value.some((v) => v > 1) ? 255 : 1; + return [value[0] / scale, value[1] / scale, value[2] / scale, value.length > 3 ? value[3] / scale : 1]; + } + return [1, 1, 1, 1]; +} + +export const colorMatrices = { + IdentityMatrix: () => ColorMatrix.identity(), + TintMatrix: (color: any) => { + const [r, g, b, a] = parseColor(color); + return diag(r, g, b, a); + }, + OpacityMatrix: (value = 1) => diag(1, 1, 1, value), + BrightnessMatrix: (value = 0) => { + const m = ColorMatrix.identity(); + m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = value; + return m; + }, + ContrastMatrix: (value = 1) => { + const m = diag(value, value, value); + m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = 0.5 * (1 - value); + return m; + }, + SaturationMatrix: (value = 1, desat: [number, number, number] = LUMA) => { + const m = ColorMatrix.identity(); + for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) m.rows[i][j] = (1 - value) * desat[j] + (i === j ? value : 0); + } + return m; + }, + InvertMatrix: (value = 1) => { + const m = diag(1 - 2 * value, 1 - 2 * value, 1 - 2 * value); + m.rows[0][4] = m.rows[1][4] = m.rows[2][4] = value; + return m; + }, + HueMatrix: (degrees = 0) => { + const a = (degrees * Math.PI) / 180; + const c = Math.cos(a); + const s = Math.sin(a); + const m = ColorMatrix.identity(); + m.rows[0] = [0.213 + c * 0.787 - s * 0.213, 0.715 - c * 0.715 - s * 0.715, 0.072 - c * 0.072 + s * 0.928, 0, 0]; + m.rows[1] = [0.213 - c * 0.213 + s * 0.143, 0.715 + c * 0.285 + s * 0.14, 0.072 - c * 0.072 - s * 0.283, 0, 0]; + m.rows[2] = [0.213 - c * 0.213 - s * 0.787, 0.715 - c * 0.715 + s * 0.715, 0.072 + c * 0.928 + s * 0.072, 0, 0]; + return m; + }, + ColorizeMatrix: (black: any, white: any) => { + const [br, bg, bb] = parseColor(black); + const [wr, wg, wb] = parseColor(white); + const m = ColorMatrix.identity(); + const channels: [number, number][] = [[br, wr], [bg, wg], [bb, wb]]; + channels.forEach(([lo, hi], i) => { + m.rows[i] = [(hi - lo) * LUMA[0], (hi - lo) * LUMA[1], (hi - lo) * LUMA[2], 0, lo]; + }); + return m; + }, +}; + +/** The older `im.matrix.*` names. */ +export const legacyMatrices = { + identity: colorMatrices.IdentityMatrix, + brightness: colorMatrices.BrightnessMatrix, + contrast: colorMatrices.ContrastMatrix, + saturation: colorMatrices.SaturationMatrix, + invert: () => colorMatrices.InvertMatrix(1), + opacity: colorMatrices.OpacityMatrix, + hue_rotate: colorMatrices.HueMatrix, + colorize: colorMatrices.ColorizeMatrix, + tint: (r: number, g: number, b: number) => diag(r, g, b), +}; + +let host: SVGSVGElement | null = null; +const known = new Map(); + +/** CSS `filter` value that applies the matrix (an SVG filter is created once per distinct matrix). */ +export function matrixFilter(matrix: ColorMatrix): string { + const values = matrix.values(); + let id = known.get(values); + if (!id) { + id = `pgm-${known.size}`; + known.set(values, id); + if (!host) { + host = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + host.setAttribute("width", "0"); + host.setAttribute("height", "0"); + host.style.position = "absolute"; + host.append(document.createElementNS("http://www.w3.org/2000/svg", "defs")); + document.body.append(host); + } + const filter = document.createElementNS("http://www.w3.org/2000/svg", "filter"); + filter.id = id; + filter.setAttribute("color-interpolation-filters", "sRGB"); + const fe = document.createElementNS("http://www.w3.org/2000/svg", "feColorMatrix"); + fe.setAttribute("type", "matrix"); + fe.setAttribute("values", values); + filter.append(fe); + host.querySelector("defs")!.append(filter); + } + return `url(#${id})`; +} diff --git a/frontend/src/player/engine.ts b/frontend/src/player/engine.ts index bf92886..a6f8595 100644 --- a/frontend/src/player/engine.ts +++ b/frontend/src/player/engine.ts @@ -7,6 +7,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } import { languageName, rememberLanguage, type Translator } from "./i18n"; import { naniText } from "./naninovel"; import { ScreenManager, type ScreenHost } from "./screens"; +import { pythonModules } from "./pymodules"; 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, @@ -321,6 +322,20 @@ export class Engine { ...this.screens.renpyApi(), // presentation-only: nothing to roll back or replay, and the next transition is chosen by `with` end_replay: () => null, + partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args), + loadable: (path: any) => this.fileUrl(String(path)) !== null, + is_init_phase: () => false, + call_stack_depth: () => this.stack.length, + const: (value: any) => value, + screenshot: () => null, + Keymap: () => null, + register_shader: () => null, + register_statement: () => null, + redraw: () => null, + stop_predict: () => null, + map_event: () => null, + count_displayables_in_layer: () => 0, + displayable: (value: any) => value, 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)), @@ -351,6 +366,10 @@ export class Engine { 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)), + print: () => null, + ...pythonModules(), + _window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; }, + _window_show: () => { this.windowShown = true; return null; }, ...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove", "Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])), str: (x: any) => pyStr(x), @@ -429,6 +448,8 @@ export class Engine { if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer"); }, setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"), + quickSave: () => void this.save("quick"), + quickLoad: () => void this.load("quick"), transform: (name) => this.game.transforms[name] ?? null, atl: (name) => this.game.atl?.[name] ?? null, }; @@ -729,7 +750,7 @@ export class Engine { this.renderer.scene(); this.clearNvl(); } - if (node.op === "hide") this.renderer.hide(node.tag); + if (node.op === "hide") this.renderer.hide(node.tagExpr ? pyStr(this.safeEval(node.tagExpr)).split(" ")[0] : node.tag); else if (node.img) this.showNode(node); if (node.with) await this.renderer.transition(this.transition(node.with)); this.advance(); @@ -878,7 +899,11 @@ export class Engine { } private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } { - const who: string | undefined = node.who; + let who: string | undefined = node.who; + if (node.whoExpr) { + const value = this.safeEval(node.whoExpr); + who = typeof value === "string" ? value : undefined; + } if (this.game.engine === "naninovel" && node.whoStr !== undefined) { const entry = this.game.naniResources?.CharacterNames?.[node.whoStr]?.[0]; return { char: {}, name: this.interp(entry ? this.tr.line(entry.tid, entry.text) : this.tr.str(node.whoStr)), mode: "adv" }; @@ -891,6 +916,9 @@ export class Engine { const char = this.game.characters[who]; if (!char) { const v = this.store[who]; + if (v && typeof v === "object" && v.__character) { + return { char: { color: v.color ?? undefined }, name: this.interp(this.tr.str(v.name ?? who)), mode: "adv" }; + } return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" }; } let name = this.tr.str(char.name ?? ""); @@ -927,7 +955,9 @@ export class Engine { if (!inMenu) this.interactionStart(); if (node.with) await this.renderer.transition(this.transition(node.with)); - let raw = this.interp(this.tr.line(node.tid, node.what)); + let raw = node.whatExpr + ? this.interp(pyStr(this.safeEval(node.whatExpr))) + : this.interp(this.tr.line(node.tid, node.what)); raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`; let prefixLen = 0; if (mode === "extend" && this.lastSay) { diff --git a/frontend/src/player/pymodules.ts b/frontend/src/player/pymodules.ts new file mode 100644 index 0000000..dc906fd --- /dev/null +++ b/frontend/src/player/pymodules.ts @@ -0,0 +1,182 @@ +// The small parts of the Python standard library games reach for: re, copy, time, datetime, +// collections.defaultdict and functools.partial. Everything is pure computation. + +import { PyDict, PyInstance, pyStr } from "./python"; + +/** dict that creates missing entries from a factory (collections.defaultdict). */ +export class PyDefaultDict extends PyDict { + constructor(public factory: (() => any) | null, entries?: Iterable<[any, any]>) { + super(entries); + } + missing(k: any) { + const value = this.factory ? this.factory() : null; + this.set(k, value); + return value; + } +} + +class ReMatch { + constructor(private m: RegExpExecArray) {} + group(...which: (number | string)[]) { + const pick = (w: number | string) => { + const v = typeof w === "string" ? this.m.groups?.[w] : this.m[w]; + return v ?? null; + }; + if (!which.length) return this.m[0]; + return which.length === 1 ? pick(which[0]) : which.map(pick); + } + groups() { + return this.m.slice(1).map((v) => v ?? null); + } + groupdict() { + return new PyDict(Object.entries(this.m.groups ?? {})); + } + start() { + return this.m.index; + } + end() { + return this.m.index + this.m[0].length; + } +} + +const FLAGS: Record = { 2: "i", 8: "m", 16: "s" }; + +function compile(pattern: string, flags = 0, global = false): RegExp { + const js = pattern.replace(/\(\?P<(\w+)>/g, "(?<$1>").replace(/\(\?P=(\w+)\)/g, "\\k<$1>").replace(/\\Z/g, "$"); + const letters = Object.entries(FLAGS).filter(([bit]) => flags & Number(bit)).map(([, f]) => f).join(""); + return new RegExp(js, letters + (global ? "g" : "") + "u"); +} + +function expand(replacement: string): string { + // \1 and \g are the group references of Python templates + return replacement.replace(/\\(\d+)/g, "$$$1").replace(/\\g<(\w+)>/g, "$$<$1>"); +} + +function deepcopy(v: any, seen = new Map()): any { + if (v === null || typeof v !== "object") return v; + if (seen.has(v)) return seen.get(v); + if (Array.isArray(v)) { + const out: any[] = []; + seen.set(v, out); + v.forEach((x) => out.push(deepcopy(x, seen))); + return out; + } + if (v instanceof PyDefaultDict) { + const out = new PyDefaultDict(v.factory); + seen.set(v, out); + v.map.forEach((x, k) => out.map.set(k, deepcopy(x, seen))); + return out; + } + if (v instanceof PyDict) { + const out = new PyDict(); + seen.set(v, out); + v.map.forEach((x, k) => out.map.set(k, deepcopy(x, seen))); + return out; + } + if (v instanceof PyInstance) { + const attrs: Record = Object.create(null); + const out = new PyInstance(v.cls, attrs); + seen.set(v, out); + for (const [k, x] of Object.entries(v.attrs)) attrs[k] = deepcopy(x, seen); + return out; + } + return v; +} + +const pad = (n: number, width = 2) => String(n).padStart(width, "0"); + +class DateTime { + constructor(public d: Date) {} + get year() { return this.d.getFullYear(); } + get month() { return this.d.getMonth() + 1; } + get day() { return this.d.getDate(); } + get hour() { return this.d.getHours(); } + get minute() { return this.d.getMinutes(); } + get second() { return this.d.getSeconds(); } + weekday() { return (this.d.getDay() + 6) % 7; } + strftime(format: string) { return strftime(format, this.d); } + isoformat() { return `${this.year}-${pad(this.month)}-${pad(this.day)}T${pad(this.hour)}:${pad(this.minute)}:${pad(this.second)}`; } +} + +function strftime(format: string, d: Date): string { + const days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; + const months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; + return format.replace(/%([a-zA-Z%])/g, (_m, c: string) => { + switch (c) { + case "Y": return String(d.getFullYear()); + case "y": return pad(d.getFullYear() % 100); + case "m": return pad(d.getMonth() + 1); + case "d": return pad(d.getDate()); + case "H": return pad(d.getHours()); + case "I": return pad(d.getHours() % 12 || 12); + case "M": return pad(d.getMinutes()); + case "S": return pad(d.getSeconds()); + case "p": return d.getHours() < 12 ? "AM" : "PM"; + case "A": return days[d.getDay()]; + case "a": return days[d.getDay()].slice(0, 3); + case "B": return months[d.getMonth()]; + case "b": return months[d.getMonth()].slice(0, 3); + case "%": return "%"; + default: return ""; + } + }); +} + +const now = () => new DateTime(new Date()); +const datetimeClass = { now, today: now, utcnow: now }; + +/** Namespaces added to the sandbox's builtins. */ +export function pythonModules(): Record { + const re = { + I: 2, IGNORECASE: 2, M: 8, MULTILINE: 8, S: 16, DOTALL: 16, + escape: (s: string) => String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + compile: (pattern: string, flags = 0) => ({ + search: (s: string) => re.search(pattern, s, flags), + match: (s: string) => re.match(pattern, s, flags), + sub: (r: string, s: string) => re.sub(pattern, r, s, 0, flags), + findall: (s: string) => re.findall(pattern, s, flags), + split: (s: string) => re.split(pattern, s, 0, flags), + }), + search: (pattern: string, s: string, flags = 0) => { + const m = compile(pattern, flags).exec(String(s)); + return m ? new ReMatch(m) : null; + }, + match: (pattern: string, s: string, flags = 0) => { + const m = compile(`^(?:${pattern})`, flags).exec(String(s)); + return m ? new ReMatch(m) : null; + }, + findall: (pattern: string, s: string, flags = 0) => + [...String(s).matchAll(compile(pattern, flags, true))].map((m) => (m.length > 2 ? m.slice(1) : m[1] ?? m[0])), + sub: (pattern: string, replacement: any, s: string, count = 0, flags = 0) => { + let left = count || Infinity; + const regex = compile(pattern, flags, true); + return String(s).replace(regex, (...args: any[]) => { + if (left-- <= 0) return args[0]; + if (typeof replacement === "function") return pyStr(replacement(new ReMatch(Object.assign(args.slice(0, -2).filter((x) => typeof x !== "object"), { index: args[args.length - 2], groups: undefined, input: s }) as any))); + return String(replacement).replace(/\\(\d+)/g, (_m: string, n: string) => args[Number(n)] ?? ""); + }); + }, + split: (pattern: string, s: string, maxsplit = 0, flags = 0) => { + const parts = String(s).split(compile(pattern, flags, true)); + return maxsplit ? [...parts.slice(0, maxsplit), parts.slice(maxsplit).join("")] : parts; + }, + }; + void expand; + const partial = (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args); + const functionModules = { partial }; + return { + re, + copy: { copy: (v: any) => (Array.isArray(v) ? [...v] : v instanceof PyDict ? new PyDict(v.map.entries()) : v), deepcopy: (v: any) => deepcopy(v) }, + time: { + time: () => Date.now() / 1000, + sleep: () => null, + strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()), + }, + datetime: Object.assign({ datetime: datetimeClass, date: datetimeClass }, datetimeClass), + collections: { + defaultdict: (factory?: any) => new PyDefaultDict(typeof factory === "function" ? () => factory() : null), + OrderedDict: () => new PyDict(), + }, + functools: functionModules, + }; +} diff --git a/frontend/src/player/python.ts b/frontend/src/player/python.ts index 46869fb..163999f 100644 --- a/frontend/src/player/python.ts +++ b/frontend/src/player/python.ts @@ -192,6 +192,8 @@ function percentFormat(fmt: string, args: any): string { } export function binop(op: string, a: any, b: any): any { + // objects (colour matrices) may define their own arithmetic + if (a && typeof a === "object" && typeof a.__binop__ === "function") return a.__binop__(op, b); switch (op) { case "+": if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b]; @@ -289,7 +291,13 @@ function methodOf(obj: any, name: string): any { index: (x) => obj.findIndex((y) => pyEq(y, x)), count: (x) => obj.filter((y) => pyEq(y, x)).length, reverse: () => void obj.reverse(), - sort: () => void obj.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), + sort: (...a: any[]) => { + const kw = a.length && a[a.length - 1]?.__kw ? a[a.length - 1].__kw : {}; + const key = typeof kw.key === "function" ? kw.key : (x: any) => x; + const sorted = obj.map((x) => [key(x), x]).sort((p, q) => (p[0] < q[0] ? -1 : p[0] > q[0] ? 1 : 0)).map((p) => p[1]); + if (truthy(kw.reverse)) sorted.reverse(); + obj.splice(0, obj.length, ...sorted); + }, clear: () => void obj.splice(0), copy: () => [...obj], add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here @@ -316,6 +324,10 @@ function methodOf(obj: any, name: string): any { return obj.get(k); }, update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)), + has_key: (k) => obj.has(k), + iteritems: () => [...obj.map.entries()], + iterkeys: () => [...obj.map.keys()], + itervalues: () => [...obj.map.values()], clear: () => obj.map.clear(), copy: () => new PyDict(obj.map.entries()), }; @@ -341,6 +353,35 @@ function methodOf(obj: any, name: string): any { }, count: (s) => obj.split(s).length - 1, find: (s) => obj.indexOf(s), + zfill: (n: number) => obj.padStart(n, "0"), + ljust: (n: number, ch = " ") => obj.padEnd(n, ch), + rjust: (n: number, ch = " ") => obj.padStart(n, ch), + center: (n: number, ch = " ") => { + const total = Math.max(0, n - obj.length); + const left = Math.floor(total / 2); + return ch.repeat(left) + obj + ch.repeat(total - left); + }, + partition: (sep: string) => { + const i = obj.indexOf(sep); + return i < 0 ? [obj, "", ""] : [obj.slice(0, i), sep, obj.slice(i + sep.length)]; + }, + rpartition: (sep: string) => { + const i = obj.lastIndexOf(sep); + return i < 0 ? ["", "", obj] : [obj.slice(0, i), sep, obj.slice(i + sep.length)]; + }, + rsplit: (sep?: string) => (sep ? obj.split(sep) : obj.trim().split(/\s+/)), + splitlines: () => obj.split(/\r?\n/), + isupper: () => obj !== obj.toLowerCase() && obj === obj.toUpperCase(), + islower: () => obj !== obj.toUpperCase() && obj === obj.toLowerCase(), + isspace: () => obj.length > 0 && /^\s+$/.test(obj), + isalnum: () => /^[\p{L}\p{N}]+$/u.test(obj), + swapcase: () => [...obj].map((c) => (c === c.toUpperCase() ? c.toLowerCase() : c.toUpperCase())).join(""), + index: (x: string) => { + const i = obj.indexOf(x); + if (i < 0) throw new Error("substring not found"); + return i; + }, + rfind: (x: string) => obj.lastIndexOf(x), isdigit: () => /^\d+$/.test(obj), isalpha: () => /^\p{L}+$/u.test(obj), }; @@ -412,12 +453,16 @@ export class Python { const m = methodOf(o, e.a); if (m) return m; const v = o instanceof PyDict ? o.get(e.a) : o[e.a]; + // methods of host objects (datetime, regex matches) need their object + if (typeof v === "function" && !(o instanceof PyDict) && !v.__py && Object.getPrototypeOf(o) !== Object.prototype) + return v.bind(o); return v === undefined ? null : v; } case "sub": { const o = this.eval(e.o); const i = this.eval(e.i); if (o instanceof PyDict) { + if (!o.has(i) && typeof (o as any).missing === "function") return (o as any).missing(i); // defaultdict if (this.strict && !o.has(i)) this.raise("KeyError", pyStr(i)); return o.has(i) ? o.get(i) : null; } diff --git a/frontend/src/player/renderer.ts b/frontend/src/player/renderer.ts index fb0b0dd..00e4951 100644 --- a/frontend/src/player/renderer.ts +++ b/frontend/src/player/renderer.ts @@ -2,6 +2,7 @@ // The stage has the game's native resolution and is scaled with a CSS transform. import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; +import { ColorMatrix, matrixFilter } from "./colormatrix"; import type { AtSpec, ImageDef, ShownImage, Transition } from "./types"; const POSITIONS: Record> = { @@ -93,25 +94,18 @@ export class Renderer { this.master.insertBefore(el, behind ?? later ?? null); } this.shown.set(tag, entry); - this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs]); + this.animate(entry, base, [...(img.def.at ?? []), ...atSpecs], img.def.timeline); } /** Start the ATL timelines of an image; each works on its own copy of the properties. */ - private animate(entry: Entry, base: Record, specs: AtSpec[]) { + private animate(entry: Entry, base: Record, specs: AtSpec[], timeline?: AtlBlock) { if (this.fast) return; const live = { ...base }; for (const spec of specs) { const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl; - if (!block) continue; - const runner = new AtlRunner({ - block: (name) => this.host.atl(name), - props: (name) => this.host.transform(name), - apply: (p) => this.style(entry.el, { ...p }), - 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)); + if (block) this.startTimeline(entry.el, block, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)), entry.runners); } + if (timeline) this.startTimeline(entry.el, timeline, live, new Map(), entry.runners); } hide(tag: string) { @@ -149,6 +143,16 @@ export class Renderer { const ref = this.host.resolveRef(def.ref); if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1); } + if (def.expr) { + // an image defined by a Python expression: its value names a picture, evaluated when shown + const value = this.host.evalExpr(def.expr); + if (typeof value === "string") { + const ref = this.host.resolveRef(value); + const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null; + return this.build(ref ?? color ?? { file: value }, depth + 1); + } + return document.createElement("div"); + } if (def.switch) { const hit = def.switch.find(([c]) => this.host.evalCond(c)); if (hit) return this.build(hit[1], depth + 1); @@ -192,6 +196,7 @@ export class Renderer { d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text }); return d; } + if (def.timeline) return this.timelineBox(def, depth); if (def.frames?.length) return this.animated(def, depth); if (def.layered) return this.layered(def, depth); if (def.file) { @@ -210,6 +215,61 @@ export class Renderer { return document.createElement("div"); } + /** Container of an ATL timeline that switches pictures; the first one is shown at once. */ + private timelineBox(def: ImageDef, depth: number): HTMLElement { + const box = document.createElement("div"); + box.style.display = "grid"; + box.dataset.timeline = "1"; + const first = firstPicture(def.timeline!); + if (first) { + const child = this.build(first, depth + 1); + child.style.gridArea = "1 / 1"; + box.append(child); + } + return box; + } + + /** Replace the picture shown in an animated element, optionally with a cross-fade. */ + private swap(box: HTMLElement, def: ImageDef, transition: Transition | null | undefined) { + const child = this.build(def); + child.style.gridArea = "1 / 1"; + const old = [...box.children]; + if (!transition || this.fast) { + box.replaceChildren(child); + return; + } + child.style.opacity = "0"; + box.append(child); + child.animate([{ opacity: 0 }, { opacity: 1 }], { duration: (transition.d ?? 0.5) * 1000, fill: "forwards" }) + .onfinish = () => { + child.style.opacity = ""; + old.forEach((e) => e.remove()); + }; + } + + /** Run a timeline on `el`; its properties are applied with the same styling as any shown image. */ + private startTimeline(el: HTMLElement, block: AtlBlock, base: Record, + vars: Map, runners: Entry["runners"]) { + const live = base; + const runner = new AtlRunner({ + block: (name) => this.host.atl(name), + props: (name) => this.host.transform(name), + apply: (p) => this.style(el, { ...p }), + eval: (expr, frame) => this.host.evalExpr(expr, frame), + image: (img, transition) => this.swap(el, img, transition), + contains: (img) => { + const child = this.build(img); + child.style.position = "absolute"; + child.style.left = child.style.top = "0"; + el.append(child); + if (img.timeline) this.startTimeline(child, img.timeline, {}, new Map(), runners); + }, + }, live, vars); + runner.watch = el; + runners.push({ runner, block }); + void runner.start(block).catch((err) => console.warn("[pgvn] ATL", err)); + } + /** An element for an image definition, for UI built outside the scene (screens). */ displayable(def: ImageDef): HTMLElement { return this.build(def); @@ -322,6 +382,7 @@ export class Renderer { 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 (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor)); if (filters.length) el.style.filter = filters.join(" "); } @@ -384,6 +445,16 @@ export class Renderer { } } +/** The first picture an ATL timeline shows (what is on screen before it starts). */ +function firstPicture(block: AtlBlock): ImageDef | null { + for (const step of block.steps) { + if (step.op === "image") return step.img; + if (step.op === "parallel") for (const b of step.blocks) { const f = firstPicture(b); if (f) return f; } + if (step.op === "block") { const f = firstPicture(step.block); if (f) return f; } + } + return null; +} + function normalize(p: Record) { if (p.align) [p.xalign, p.yalign] = p.align; if (p.pos) [p.xpos, p.ypos] = p.pos; diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 3cfa0fa..ebcd7ed 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -2,6 +2,7 @@ // whenever game state may have changed. Expressions run in the same Python sandbox as the script. import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; +import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix"; import { PyDict, pyStr, truthy, type Python } from "./python"; import type { AtSpec, Expr } from "./types"; @@ -35,6 +36,8 @@ export interface ScreenHost { mainMenu(): void; openMenu(): void; openUrl(url: string): void; + quickSave(): void; + quickLoad(): void; setLanguage(code: string | null): void; transform(name: string): Record | null; atl(name: string): AtlBlock | null; @@ -660,6 +663,68 @@ export class ScreenManager { const result = typeof a === "function" ? a(0, 0) : null; return this.displayable(Array.isArray(result) ? result[0] : result, depth + 1); } + case "text": { + const box = document.createElement("div"); + const size = num(value.kw.size) ?? this.host.baseSize; + this.host.text(box, pyStr(a), size); + if (typeof value.kw.color === "string") box.style.color = value.kw.color; + return box; + } + case "image": return this.displayable(a, depth + 1); + case "matrixcolor": { + const child = this.displayable(a, depth + 1); + if (child && b instanceof ColorMatrix) child.style.filter = matrixFilter(b); + return child; + } + case "scale": { + const child = this.displayable(a, depth + 1); + if (child && num(value.args[1]) !== null) { + child.style.width = `${value.args[1]}px`; + child.style.height = `${value.args[2]}px`; + } + return child; + } + case "factorscale": { + const child = this.displayable(a, depth + 1); + if (child && num(b) !== null) { + child.style.transformOrigin = "0 0"; + child.style.transform = `scale(${b}, ${value.args[2] ?? b})`; + } + return child; + } + case "flip": { + const child = this.displayable(a, depth + 1); + if (child) child.style.transform = `scale(${truthy(value.kw.horizontal) ? -1 : 1}, ${truthy(value.kw.vertical) ? -1 : 1})`; + return child; + } + case "filter": { + const child = this.displayable(a, depth + 1); + if (child) { + const how = String(value.kw.how); + if (how === "alpha") child.style.opacity = String(b ?? 1); + else child.style.filter = how === "blur" ? `blur(${b ?? 0}px)` : `${how}(1)`; + } + return child; + } + case "composite": { + const box = document.createElement("div"); + const size = a; + box.style.position = "relative"; + if (Array.isArray(size)) { + box.style.width = `${size[0]}px`; + box.style.height = `${size[1]}px`; + } + for (let i = 1; i + 1 < value.args.length; i += 2) { + const child = this.displayable(value.args[i + 1], depth + 1); + const at = value.args[i]; + if (!child || !Array.isArray(at)) continue; + child.style.position = "absolute"; + child.style.left = `${at[0]}px`; + child.style.top = `${at[1]}px`; + box.append(child); + } + return box; + } case "transform": { const child = this.displayable(a, depth + 1); if (!child) return null; @@ -763,6 +828,10 @@ export class ScreenManager { const paint = () => { const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive") : selected ? (hover ? "selected_hover" : "selected_idle") : hover ? "hover" : "idle"; + if (el.dataset.state && el.dataset.state !== state) { + // `on hover:` / `on idle:` handlers of the button's transforms + for (const entry of (el as any).__atl ?? []) entry.runner.event(entry.block, state); + } el.dataset.state = state; if (images) { const file = images[state]; @@ -867,6 +936,7 @@ export class ScreenManager { const style = el.style; const alpha = num(g("alpha")); if (alpha !== null) style.opacity = String(alpha); + applyFilters(el, { blur: g("blur"), matrixcolor: g("matrixcolor") }); // size let [width, height] = [g("xsize") ?? g("width"), g("ysize") ?? g("height")]; const both = g("xysize"); @@ -979,7 +1049,7 @@ export class ScreenManager { 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)` : ""; + applyFilters(el, p); }; for (const spec of specs) { Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {}); @@ -1000,6 +1070,8 @@ export class ScreenManager { apply: (p) => apply(p), eval: (expr, frame) => this.evalWith(expr, frame), }, live, bindArguments(block, spec, (e) => this.ev(e))); + runner.watch = el; + ((el as any).__atl ??= []).push({ runner, block }); ctx.inst.runners.push(runner); const inst = ctx.inst; void runner.start(block).then((completed) => { @@ -1095,7 +1167,10 @@ export class ScreenManager { this.assign(a, truthy(now) && now !== off ? off : on, owner); return; } + case "SetLocalVariable": case "SetScreenVariable": owner?.vars.set(a, b); return; + case "QuickSave": this.host.quickSave(); return; + case "QuickLoad": this.host.quickLoad(); throw new ActionAbort(); case "ToggleScreenVariable": { const now = owner?.vars.get(a); const on = action.args.length > 1 ? b : true; @@ -1148,7 +1223,7 @@ export class ScreenManager { 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); + "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad"]) 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"); @@ -1162,6 +1237,27 @@ export class ScreenManager { out.Transform = shape("transform"); out.DynamicDisplayable = shape("dynamic"); out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value)); + // Character(...) built by game code: kept as a record the say statement can read + out.Character = (...args: any[]) => { + const last = args[args.length - 1]; + const kw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw") ? last.__kw : {}; + return { __character: true, name: args.length && typeof args[0] === "string" ? args[0] : null, color: kw.color ?? null, kw }; + }; + out.Text = shape("text"); + out.Image = shape("image"); + out.Fixed = shape("composite"); + out.AlphaMask = (base: any) => base; + out.At = (child: any) => child; + Object.assign(out, colorMatrices); + out.im = { + MatrixColor: shape("matrixcolor"), Scale: shape("scale"), FactorScale: shape("factorscale"), Flip: shape("flip"), + Crop: shape("crop"), Composite: shape("composite"), + Grayscale: (child: any) => new Displayable("filter", [child], { how: "grayscale" }), + Sepia: (child: any) => new Displayable("filter", [child], { how: "sepia" }), + Alpha: (child: any, amount: number) => new Displayable("filter", [child, amount], { how: "alpha" }), + Blur: (child: any, radius: number) => new Displayable("filter", [child, radius], { how: "blur" }), + matrix: legacyMatrices, + }; 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); @@ -1191,6 +1287,15 @@ export class ScreenManager { } } +/** blur and matrixcolor of a widget or transform, combined into one CSS filter. */ +function applyFilters(el: HTMLElement, p: Props) { + const filters: string[] = []; + if (num(p.blur) !== null && p.blur > 0) filters.push(`blur(${p.blur}px)`); + if (p.matrixcolor instanceof ColorMatrix) filters.push(matrixFilter(p.matrixcolor)); + if (filters.length) el.style.filter = filters.join(" "); + else if (p.blur !== undefined || p.matrixcolor !== undefined) el.style.filter = ""; +} + function lengthOf(v: any): string { return typeof v === "number" ? PX_UNIT(v) : "0px"; } diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index b874836..deec017 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -28,6 +28,8 @@ export interface ImageDef { props?: Record; frames?: { img: ImageDef; d?: number }[]; repeat?: boolean; + /** ATL timeline that switches pictures and moves them (image blocks with parallel, contains ...). */ + timeline?: import("./atl").AtlBlock; layered?: Layer[]; at?: AtSpec[]; selected?: string[]; // attributes chosen for a layered image (set by the engine) diff --git a/frontend/tests/renpy-runtime.test.mjs b/frontend/tests/renpy-runtime.test.mjs new file mode 100644 index 0000000..fc00ef3 --- /dev/null +++ b/frontend/tests/renpy-runtime.test.mjs @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +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 }); +globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 4); + +const compiled = await build({ + stdin: { contents: `export * from "./python"; export * from "./atl"; export * from "./colormatrix"; export * from "./pymodules";`, + resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" }, + bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent", +}); +const m = await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`); +const { Python, AtlRunner, ColorMatrix, colorMatrices, legacyMatrices, matrixFilter, pythonModules, PyDict } = m; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +const name = (id) => ({ k: "name", id }); +const attr = (o, a) => ({ k: "attr", o, a }); +const call = (f, ...a) => ({ k: "call", f, a: a.map((v) => (v && v.k ? v : { k: "const", v })), kw: {} }); + +function python(extra = {}) { + const store = {}; + const builtins = { ...pythonModules(), ...colorMatrices, ...extra }; + 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]; }, + }); + return { py, store }; +} + +test("colour matrices compose like Ren'Py's: the right one applies first", () => { + const brighter = colorMatrices.BrightnessMatrix(0.2); + const half = colorMatrices.TintMatrix("#808080"); + const both = half.times(brighter); // brighten, then tint + assert.ok(Math.abs(both.rows[0][4] - 0.2 * (128 / 255)) < 1e-6); + assert.deepEqual(ColorMatrix.identity().times(brighter).rows, brighter.rows); + const gray = colorMatrices.SaturationMatrix(0); + assert.ok(Math.abs(gray.rows[0][0] - 0.2126) < 1e-9 && Math.abs(gray.rows[1][2] - 0.0722) < 1e-9); + assert.equal(legacyMatrices.tint(1, 0.5, 0).rows[1][1], 0.5); + assert.match(matrixFilter(both), /^url\(#pgm-\d+\)$/); + assert.equal(matrixFilter(both), matrixFilter(both), "equal matrices share one SVG filter"); + assert.equal(document.querySelectorAll("feColorMatrix").length, 1); +}); + +test("the sandbox multiplies matrices with `*` and refuses other arithmetic on them", () => { + const { py, store } = python(); + py.exec([{ op: "set", t: name("m"), v: { k: "bin", op: "*", l: call(name("TintMatrix"), "#ff0000"), r: call(name("BrightnessMatrix"), 0.1) } }]); + assert.ok(store.m instanceof ColorMatrix); + assert.throws(() => py.eval({ k: "bin", op: "+", l: name("m"), r: name("m") }), /перемножать/); +}); + +test("re, copy, defaultdict, datetime and string helpers behave like their Python versions", () => { + const { py, store } = python(); + const re = (fn, ...args) => py.eval(call(attr(name("re"), fn), ...args)); + assert.equal(re("sub", "(\\d+)", "<\\1>", "a12b3"), "a<12>b<3>"); + assert.equal(re("match", "^ab", "abc").group(), "ab"); + assert.equal(re("search", "(?P\\d+)", "x42").group("n"), "42"); + assert.deepEqual(re("findall", "\\d", "a1b2"), ["1", "2"]); + const original = new PyDict([["k", [1, 2]]]); + store.original = original; + const copied = py.eval(call(attr(name("copy"), "deepcopy"), name("original"))); + copied.get("k").push(3); + assert.deepEqual(original.get("k"), [1, 2]); + store.counts = py.eval(call(attr(name("collections"), "defaultdict"), { k: "name", id: "list" })); + py.scope.set("list", () => []); + store.counts = py.eval(call(attr(name("collections"), "defaultdict"), name("list"))); + py.eval(call(attr({ k: "sub", o: name("counts"), i: { k: "const", v: "a" } }, "append"), 1)); + assert.deepEqual(store.counts.get("a"), [1]); + assert.equal(py.eval(call(attr({ k: "const", v: "7" }, "zfill"), 3)), "007"); + assert.deepEqual(py.eval(call(attr({ k: "const", v: "a=b" }, "partition"), "=")), ["a", "=", "b"]); + const hour = py.eval(attr(call(attr(name("datetime"), "now")), "hour")); + assert.ok(hour >= 0 && hour < 24); +}); + +test("ATL image steps, finite repeat, time and function work in a timeline", async () => { + const shown = []; + const props = {}; + const { py } = python(); + py.scope.set("drift", (trans) => { trans.xpos = 0.75; return null; }); + const host = { + block: () => null, props: () => null, apply: () => {}, + eval: (expr) => py.eval(expr), + image: (img, transition) => shown.push([img.file, transition?.d]), + }; + const runner = new AtlRunner(host, props); + const started = performance.now(); + const completed = await runner.start({ repeat: 2, steps: [ + { op: "image", img: { file: "a.webp" }, with: { t: "dissolve", d: 0.2 } }, + { op: "pause", t: 0.02 }, + { op: "image", img: { file: "b.webp" }, with: null }, + { op: "time", t: 0.05 }, + ] }); + assert.equal(completed, true); + assert.deepEqual(shown, [["a.webp", 0.2], ["b.webp", undefined], ["a.webp", 0.2], ["b.webp", undefined]]); + assert.ok(performance.now() - started >= 95, "`time 0.05` waits for the block clock, twice"); + await new AtlRunner(host, props).start({ repeat: null, steps: [{ op: "function", f: { k: "name", id: "drift" } }] }); + assert.equal(props.xpos, 0.75, "function f mutates the transform properties"); +}); + +test("an animation stops by itself when its element leaves the page", async () => { + const box = document.createElement("div"); + document.body.append(box); + let frames = 0; + const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => { frames++; } }, {}); + runner.watch = box; + const running = runner.start({ repeat: true, steps: [{ op: "move", warp: "linear", t: 0.02, props: { alpha: 0 } }] }); + await wait(40); + box.remove(); + assert.equal(await running, false, "stopped, not finished"); + const seen = frames; + await wait(40); + assert.equal(frames, seen); +}); + +test("on hover handlers run when the button reports the event", async () => { + const props = { alpha: 1 }; + const runner = new AtlRunner({ block: () => null, props: () => null, apply: () => {} }, props); + const block = { repeat: null, steps: [ + { op: "on", events: ["hover"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 0.5 } }] } }, + { op: "on", events: ["idle"], block: { repeat: null, steps: [{ op: "set", props: { alpha: 1 } }] } }, + ] }; + runner.event(block, "hover"); + await wait(20); + assert.equal(props.alpha, 0.5); + runner.event(block, "idle"); + await wait(20); + assert.equal(props.alpha, 1); +});