feat: ATL pictures/contains/function/time, colour matrices and more Python for Ren'Py

ATL
- Image blocks become timelines: picture steps with dissolve, parallel, contains,
  finite repeat, time, function f(trans, st, at), calls of other transforms with
  arguments, on hover/idle events for buttons. Animations stop when their element
  leaves the page
- Pictures defined by an expression (get_face_path("base")) are drawn at show time
- Purely graphical hints (shader, mesh, gl_*, u_*) are accepted and dropped

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

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

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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:05:14 +03:00
1 parent 62ece25902
commit ea03cebf6a
21 files changed
+1263 -92

No files matched your search

+19 -7
View File
@@ -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). Подробности устройства — в [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
+65 -10
View File
@@ -10,6 +10,10 @@ A block becomes ``{"steps": [...], "repeat": None | True | n}``. Steps:
* ``{"op": "block", "block": block}`` * ``{"op": "block", "block": block}``
* ``{"op": "on", "events": [...], "block": block}`` * ``{"op": "on", "events": [...], "block": block}``
* ``{"op": "use", "name": transform}`` * ``{"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 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):``). 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 typing import Any
from app.converter import pyexpr from app.converter import pyexpr
from app.converter.exprtokens import read_value
from app.converter.rpy_helpers import POSITION_PROPS from app.converter.rpy_helpers import POSITION_PROPS
from app.converter.rpy_lexer import Line from app.converter.rpy_lexer import Line
Block = dict[str, Any] 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 # 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. # 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))?$") 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 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]: def tokens(text: str) -> list[str]:
@@ -65,9 +75,12 @@ def tokens(text: str) -> list[str]:
class AtlParser: class AtlParser:
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None], 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.warn = warn
self.on_use = on_use 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 # name -> props when `name` is a known static transform (inlined), else None
self.static_transform = is_static self.static_transform = is_static
@@ -101,8 +114,12 @@ class AtlParser:
i = 0 i = 0
while i < len(parts): while i < len(parts):
key = parts[i] key = parts[i]
if key in IGNORED: glued = GLUED.match(key) # decompiled `anchor(0.5, 0.5)`
i += 2 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 continue
if key not in POSITION_PROPS: if key not in POSITION_PROPS:
self.warn(f"Не поддерживается ATL: свойство {key}", line) self.warn(f"Не поддерживается ATL: свойство {key}", line)
@@ -110,11 +127,14 @@ class AtlParser:
if i + 1 >= len(parts): if i + 1 >= len(parts):
self.warn(f"Не поддерживается ATL: у {key} нет значения", line) self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
return out return out
value = self.value(parts[i + 1], line) source, i = read_value(parts, i + 1)
value = self.value(source, line)
if value is None: if value is None:
return out 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 out[key] = list(value) if isinstance(value, tuple) else value
i += 2
return out return out
# --- block ---------------------------------------------------------------------- # --- block ----------------------------------------------------------------------
@@ -131,7 +151,10 @@ class AtlParser:
continue continue
first = parts[0] first = parts[0]
i += 1 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) repeat = True if len(parts) == 1 else self.number(parts[1], line)
if repeat is not True and repeat is not None: if repeat is not True and repeat is not None:
repeat = repeat if isinstance(repeat, dict) else int(repeat) repeat = repeat if isinstance(repeat, dict) else int(repeat)
@@ -145,10 +168,12 @@ class AtlParser:
self.move(first, parts, line, steps) self.move(first, parts, line, steps)
elif first in IGNORED: elif first in IGNORED:
continue 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) props = self.props(parts, line)
if props: if props:
steps.append({"op": "set", "props": 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): elif len(parts) == 1 and re.fullmatch(r"[A-Za-z_]\w*", first):
self.on_use(first) self.on_use(first)
known = self.static_transform(first) known = self.static_transform(first)
@@ -157,6 +182,19 @@ class AtlParser:
self.warn(f"Не поддерживается ATL: {first}", line) self.warn(f"Не поддерживается ATL: {first}", line)
return {"steps": steps, "repeat": repeat} 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: def move(self, warp: str, parts: list[str], line: Line, steps: list) -> None:
base = WARPER.match(warp).group(1) # type: ignore[union-attr] base = WARPER.match(warp).group(1) # type: ignore[union-attr]
if len(parts) < 2: 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: 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.""" """Handle parallel/choice/on groups. Returns the index of the next unread line."""
line = lines[index] line = lines[index]
if first in ("time", "contains", "function"): if first == "function":
self.warn(f"Не поддерживается ATL: {first}", line) 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 return index + 1
if first == "block": if first == "block":
steps.append({"op": "block", "block": self.block(line.block)}) steps.append({"op": "block", "block": self.block(line.block)})
+72 -3
View File
@@ -111,11 +111,27 @@ SetScreenVariable ToggleScreenVariable Show Hide ShowTransient Function NullActi
Quit MainMenu If GetTooltip StaticValue VariableValue AnimatedValue FieldValue SelectedIf Quit MainMenu If GetTooltip StaticValue VariableValue AnimatedValue FieldValue SelectedIf
SensitiveIf Queue ToggleScreen Language ShowMenu OpenURL Crop LiveCrop Solid Frame Null Transform SensitiveIf Queue ToggleScreen Language ShowMenu OpenURL Crop LiveCrop Solid Frame Null Transform
DynamicDisplayable Color Dissolve Fade ImageDissolve MoveTransition CropMove PushMove Pixellate 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", CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "renpy.seen_image",
"renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction", "renpy.show_screen", "renpy.hide_screen", "renpy.restart_interaction",
"renpy.get_screen", "renpy.call_screen"} "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]]: 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]]) [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: def audit_ir(data) -> None:
"""Reject calls which the browser would otherwise silently replace by None.""" """Reject calls which the browser would otherwise silently replace by None."""
mark_screen_calls(data) mark_screen_calls(data)
@@ -241,5 +310,5 @@ def audit_ir(data) -> None:
visit(definition, f"screen {name}") visit(definition, f"screen {name}")
for name in sorted(data.screen_used - set(data.screens)): for name in sorted(data.screen_used - set(data.screens)):
data.warn("Не найден screen", name) data.warn("Не найден screen", name)
visit(data.init_code, "init") visit(reachable_init_code(data, used_screens), "init")
visit(data.images, "image") visit(data.images, "image")
+28
View File
@@ -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
+19
View File
@@ -344,6 +344,8 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st
if "frames" in img: if "frames" in img:
img["frames"] = [{**f, "img": _resolve_image(f["img"], aliases, missing)} img["frames"] = [{**f, "img": _resolve_image(f["img"], aliases, missing)}
for f in img["frames"]] for f in img["frames"]]
if "timeline" in img:
img["timeline"] = _resolve_timeline(img["timeline"], aliases, missing)
if "layered" in img: if "layered" in img:
layers = [] layers = []
for layer in img["layered"]: 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 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]]: def anchors(labels: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
"""Positions of `label` markers (nested labels, named menus) inside label bodies.""" """Positions of `label` markers (nested labels, named menus) inside label bodies."""
found: dict[str, dict[str, Any]] = {} found: dict[str, dict[str, Any]] = {}
+47 -1
View File
@@ -24,7 +24,8 @@ CMP_OPS = {
UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"} UNARY_OPS = {ast.Not: "not", ast.USub: "-", ast.UAdd: "+", ast.Invert: "~"}
# Modules whose import is harmless: the player provides the functions it supports. # 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): 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): case ast.Try(body=body, handlers=handlers, orelse=orelse, finalbody=final):
return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers], return [{"op": "try", "body": stmts(body), "handlers": [handler(h) for h in handlers],
"else": stmts(orelse), "final": stmts(final)}] "else": stmts(orelse), "final": stmts(final)}]
case ast.Match(subject=subject, cases=cases):
return match_statement(subject, cases)
case ast.Raise(exc=exc): case ast.Raise(exc=exc):
return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped return [{"op": "raise", "v": None if exc is None else expr(exc)}] # `from cause` is dropped
case ast.Assert(test=test, msg=msg): 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.""" """``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, return {"types": None if node.type is None else expr(node.type), "name": node.name,
"body": stmts(node.body)} "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]
+1 -1
View File
@@ -11,7 +11,7 @@ Node = dict[str, Any]
POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter", POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xcenter",
"ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset", "ycenter", "zoom", "xzoom", "yzoom", "alpha", "rotate", "xoffset", "yoffset",
"align", "pos", "anchor", "xysize", "size", "blur"} "align", "pos", "anchor", "xysize", "size", "blur", "matrixcolor", "xycenter"}
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"} _SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
+149 -13
View File
@@ -297,12 +297,25 @@ class RpyParser:
return c.string_raw() is not None or c.peek('"') or c.peek("'") return c.string_raw() is not None or c.peek('"') or c.peek("'")
def dollar(self, code: str, line: Line) -> list[Node]: def dollar(self, code: str, line: Line) -> list[Node]:
stmts = self.python(code, line) return self.python_nodes(self.python(code, line), line)
if len(stmts) == 1 and stmts[0]["op"] == "eval":
special = self.renpy_call(stmts[0]["e"], line) def python_nodes(self, stmts: list[Stmt], line: Line) -> list[Node]:
if special is not None: """Statements become one `exec` node, except calls that are really script commands
return special (renpy.show, renpy.say, narrator("...")): those become the matching nodes."""
return [{"op": "exec", "code": stmts}] if stmts else [] 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: def renpy_call(self, e: Expr, line: Line) -> list[Node] | None:
"""Turn common renpy.* calls into native script nodes.""" """Turn common renpy.* calls into native script nodes."""
@@ -332,17 +345,66 @@ class RpyParser:
if "channel" in kw and kw["channel"].get("k") == "const": if "channel" in kw and kw["channel"].get("k") == "const":
ch = kw["channel"]["v"] ch = kw["channel"]["v"]
return [{"op": "stop", "ch": ch, "fadeout": _const(kw.get("fadeout"))}] return [{"op": "stop", "ch": ch, "fadeout": _const(kw.get("fadeout"))}]
if fname == "renpy.show" and args and args[0].get("k") == "const": if fname == "renpy.show" and args:
return [{"op": "show", "img": {"name": str(args[0]["v"]).split()}, "at": [], at = self.at_list(kw.get("at_list"), line)
"with": None}] if at is None:
if fname == "renpy.hide" and args and args[0].get("k") == "const": 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": 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": if fname == "renpy.scene":
return [{"op": "scene", "img": None, "at": [], "with": None}] return [{"op": "scene", "img": None, "at": [], "with": None}]
if fname == "renpy.with_statement" and args: if fname == "renpy.with_statement" and args:
return [{"op": "with", "t": self.transition_expr(args[0])}] return [{"op": "with", "t": self.transition_expr(args[0])}]
return None 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 ---------------------------------------------------------------- # init / python ----------------------------------------------------------------
def st_init(self, line: Line) -> list[Node]: def st_init(self, line: Line) -> list[Node]:
@@ -369,8 +431,7 @@ class RpyParser:
if c.keyword("early"): if c.keyword("early"):
self.init_python(line.block_source(), line) self.init_python(line.block_source(), line)
return [] return []
code = self.python(line.block_source(), line) return self.python_nodes(self.python(line.block_source(), line), line)
return [{"op": "exec", "code": code}] if code else []
def st_rpy(self, line: Line) -> list[Node]: def st_rpy(self, line: Line) -> list[Node]:
return [] return []
@@ -616,6 +677,38 @@ class RpyParser:
MOTION_WORDS = {"linear", "ease", "easein", "easeout", "easeinout", "parallel", "choice", "repeat", MOTION_WORDS = {"linear", "ease", "easein", "easeout", "easeinout", "parallel", "choice", "repeat",
"block", "on"} "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: def displayable_line(self, child: Line) -> bool:
if self.cursor(child).string() is not None: if self.cursor(child).string() is not None:
@@ -623,9 +716,51 @@ class RpyParser:
m = _CALL_LINE.match(child.text) m = _CALL_LINE.match(child.text)
return bool(m and m.group(1) in _DISPLAYABLE_CALLS) 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: 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)] 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)] 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( if len(shown) == 1 and any(
(word := Cursor(child.text).peek_word()) in self.MOTION_WORDS or WARPER.match(word or "") (word := Cursor(child.text).peek_word()) in self.MOTION_WORDS or WARPER.match(word or "")
for child in rest): for child in rest):
@@ -881,7 +1016,8 @@ class RpyParser:
uses.add(name) uses.add(name)
parser = AtlParser(warn, lambda name: self.d.transforms.get(name) if name not in self.d.atl 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) block = parser.block(lines)
static = static_props(block) static = static_props(block)
if static is not None: if static is not None:
+5 -28
View File
@@ -21,6 +21,7 @@ from typing import Any
from app.converter import pyexpr from app.converter import pyexpr
from app.converter.atl import WARPER from app.converter.atl import WARPER
from app.converter.exprtokens import read_value
from app.converter.pyexpr import Unsupported from app.converter.pyexpr import Unsupported
from app.converter.rpy_lexer import Line 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 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 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 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 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()) 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 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 ALL = LAYOUT | LOOK | TEXT | BEHAVIOUR
SCREEN_PROPS = {"zorder", "modal", "tag", "variant", "style_prefix", "layer", "sensitive", "style"} SCREEN_PROPS = {"zorder", "modal", "tag", "variant", "style_prefix", "layer", "sensitive", "style"}
DESKTOP_VARIANTS = {"pc", "large", "web", "medium", "small"} 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) GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\)|\[.*\])$", re.S)
@@ -110,29 +109,6 @@ def tokens(text: str) -> list[str]:
return out 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: class ScreenParser:
def __init__(self, warn: Callable[[str, Line], None], transform_ref: Callable[[str, Line], dict], def __init__(self, warn: Callable[[str, Line], None], transform_ref: Callable[[str, Line], dict],
atl: Callable[[list[Line]], tuple[dict, dict | None]] | None = None): atl: Callable[[list[Line]], tuple[dict, dict | None]] | None = None):
@@ -191,7 +167,8 @@ class ScreenParser:
i = 0 i = 0
while i < len(lines): while i < len(lines):
line = lines[i] 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 i += 1
if not words: if not words:
continue continue
@@ -199,7 +176,7 @@ class ScreenParser:
if first in ("if", "showif"): if first in ("if", "showif"):
branches = [self.branch(first, words, line)] branches = [self.branch(first, words, line)]
while i < len(lines): 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"): if not nxt or nxt[0] not in ("elif", "else"):
break break
branches.append(self.branch(nxt[0], nxt, lines[i])) branches.append(self.branch(nxt[0], nxt, lines[i]))
+1 -1
View File
@@ -75,7 +75,7 @@ def validate(doc, manifest):
raise ValueError("Некорректный оператор") raise ValueError("Некорректный оператор")
identity(n) identity(n)
op = n["op"] op = n["op"]
for key in ("expr", "c", "d"): for key in ("expr", "c", "d", "whatExpr", "whoExpr", "tagExpr"):
if isinstance(n.get(key), dict): if isinstance(n.get(key), dict):
expression(n[key]) expression(n[key])
if op in ("call_screen", "show_screen"): if op in ("call_screen", "show_screen"):
+1 -2
View File
@@ -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(): 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')", for body in ("time 1.0:\n xalign 1.0", "knot (0, 0)", "circles 0.5", "contains 'a.png'"):
"contains:\n 'a.png'"):
unused = script(f'transform odd:\n {body}\nlabel start:\n "Hi"') unused = script(f'transform odd:\n {body}\nlabel start:\n "Hi"')
require_supported_scripts(unused) # nothing shows it, so it cannot break the game 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"') used = script(f'transform odd:\n {body}\nlabel start:\n show eileen at odd\n "Hi"')
+11 -6
View File
@@ -1,9 +1,10 @@
# pgames-manager — архитектура # pgames-manager — архитектура
Локальная библиотека визуальных новелл (сначала Ren'Py). Игры не запускаются Локальная библиотека игр, которые играются в браузере: визуальные новеллы, «песочницы», текстовые
оригинальным движком: архив разбирается на сервере, скрипт и ассеты квесты (см. [README](../README.md)). Игры не запускаются оригинальным движком: архив разбирается на
перекладываются в собственный формат **PGVN**, а играется копия в нашем сервере, скрипт и ассеты перекладываются в собственный формат **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`); [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, сохранения); * дальнейшее дробление `engine.ts` на модули (рендер сцены, UI, сохранения);
* типизация backend под `mypy` (сейчас проверка справочная). * типизация backend под `mypy` (сейчас проверка справочная).
+98 -3
View File
@@ -1,5 +1,7 @@
// Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image. // Runs ATL timelines (see backend/app/converter/atl.py) on the properties of one shown image.
import { ColorMatrix } from "./colormatrix";
export type AtlProps = Record<string, any>; export type AtlProps = Record<string, any>;
/** A literal, or an expression evaluated when the timeline runs (`{ e: expr }`). */ /** A literal, or an expression evaluated when the timeline runs (`{ e: expr }`). */
@@ -20,7 +22,11 @@ export type AtlStep =
| { op: "choice"; blocks: { w: AtlValue<number>; block: AtlBlock }[] } | { op: "choice"; blocks: { w: AtlValue<number>; block: AtlBlock }[] }
| { op: "block"; block: AtlBlock } | { op: "block"; block: AtlBlock }
| { op: "on"; events: string[]; block: AtlBlock } | { op: "on"; events: string[]; block: AtlBlock }
| { op: "use"; name: string }; | { op: "use"; name: string; args?: any[]; kw?: Record<string, any> }
| { op: "image"; img: any; with: any }
| { op: "contains"; img: any }
| { op: "function"; f: any }
| { op: "time"; t: AtlValue<number> };
export interface AtlHost { export interface AtlHost {
/** Timeline of a named animated transform. */ /** Timeline of a named animated transform. */
@@ -31,6 +37,10 @@ export interface AtlHost {
apply(props: AtlProps): void; apply(props: AtlProps): void;
/** Evaluate a run-time value with the transform's parameters in scope. */ /** Evaluate a run-time value with the transform's parameters in scope. */
eval?(expr: any, vars: Map<string, any>): any; eval?(expr: any, vars: Map<string, any>): 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. */ /** 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 { export class AtlRunner {
private token = 0; 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<string, any> = new Map()) {} constructor(private host: AtlHost, private props: AtlProps, private vars: Map<string, any> = new Map()) {}
/** Resolve `{ e: expr }` values (also inside tuples). */ /** Resolve `{ e: expr }` values (also inside tuples). */
private val<T = any>(v: AtlValue<T> | undefined): any { private val<T = any>(v: AtlValue<T> | undefined): any {
if (Array.isArray(v)) return v.map((x) => this.val(x)); if (Array.isArray(v)) return v.map((x) => this.val(x));
@@ -138,17 +153,41 @@ export class AtlRunner {
private alive(token: number) { private alive(token: number) {
if (token !== this.token) throw new Stopped(); 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() { private apply() {
this.host.apply(this.props); this.host.apply(this.props);
} }
private blockStart = 0;
private async run(block: AtlBlock, token: number, depth: number, event: string): Promise<void> { private async run(block: AtlBlock, token: number, depth: number, event: string): Promise<void> {
if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность"); if (depth > MAX_DEPTH) throw new Error("ATL: слишком глубокая вложенность");
let loops = 0; let loops = 0;
for (;;) { for (;;) {
const startedAt = performance.now(); const startedAt = performance.now();
this.blockStart = startedAt;
let waited = false; let waited = false;
for (let i = 0; i < block.steps.length; i++) { for (let i = 0; i < block.steps.length; i++) {
this.alive(token); this.alive(token);
@@ -203,8 +242,18 @@ export class AtlRunner {
return true; return true;
case "use": { case "use": {
const named = this.host.block(step.name); const named = this.host.block(step.name);
if (named) await this.run(named, token, depth + 1, event); if (named) {
else { // `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); const props = this.host.props(step.name);
if (props) { if (props) {
Object.assign(this.props, props); Object.assign(this.props, props);
@@ -213,11 +262,53 @@ export class AtlRunner {
} }
return true; 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": case "on":
return false; 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) { private async sleep(seconds: number, token: number) {
const end = performance.now() + seconds * 1000; const end = performance.now() + seconds * 1000;
while (performance.now() < end) { while (performance.now() < end) {
@@ -265,6 +356,10 @@ export class AtlRunner {
function mix(a: any, b: any, k: number): any { function mix(a: any, b: any, k: number): any {
if (typeof a === "number" && typeof b === "number") return a + (b - a) * k; 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)); 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; return k >= 1 ? b : a;
} }
+163
View File
@@ -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<string, string>();
/** 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})`;
}
+33 -3
View File
@@ -7,6 +7,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints }
import { languageName, rememberLanguage, type Translator } from "./i18n"; import { languageName, rememberLanguage, type Translator } from "./i18n";
import { naniText } from "./naninovel"; import { naniText } from "./naninovel";
import { ScreenManager, type ScreenHost } from "./screens"; import { 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 { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
import type { import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
@@ -321,6 +322,20 @@ export class Engine {
...this.screens.renpyApi(), ...this.screens.renpyApi(),
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with` // presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
end_replay: () => null, end_replay: () => null,
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)), 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)), 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)), 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), 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 // a read-only view: `"name" in globals()` works, writes do not reach the game
globals: () => new PyDict(Object.entries(this.store)), 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", ...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
"Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])), "Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])),
str: (x: any) => pyStr(x), str: (x: any) => pyStr(x),
@@ -429,6 +448,8 @@ export class Engine {
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer"); if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
}, },
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"), 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, transform: (name) => this.game.transforms[name] ?? null,
atl: (name) => this.game.atl?.[name] ?? null, atl: (name) => this.game.atl?.[name] ?? null,
}; };
@@ -729,7 +750,7 @@ export class Engine {
this.renderer.scene(); this.renderer.scene();
this.clearNvl(); 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); else if (node.img) this.showNode(node);
if (node.with) await this.renderer.transition(this.transition(node.with)); if (node.with) await this.renderer.transition(this.transition(node.with));
this.advance(); this.advance();
@@ -878,7 +899,11 @@ export class Engine {
} }
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } { 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) { if (this.game.engine === "naninovel" && node.whoStr !== undefined) {
const entry = this.game.naniResources?.CharacterNames?.[node.whoStr]?.[0]; 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" }; 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]; const char = this.game.characters[who];
if (!char) { if (!char) {
const v = this.store[who]; 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" }; return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" };
} }
let name = this.tr.str(char.name ?? ""); let name = this.tr.str(char.name ?? "");
@@ -927,7 +955,9 @@ export class Engine {
if (!inMenu) this.interactionStart(); if (!inMenu) this.interactionStart();
if (node.with) await this.renderer.transition(this.transition(node.with)); 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 ?? ""}`; raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
let prefixLen = 0; let prefixLen = 0;
if (mode === "extend" && this.lastSay) { if (mode === "extend" && this.lastSay) {
+182
View File
@@ -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<number, string> = { 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<name> 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, any>()): 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<string, any> = 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<string, any> {
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,
};
}
+46 -1
View File
@@ -192,6 +192,8 @@ function percentFormat(fmt: string, args: any): string {
} }
export function binop(op: string, a: any, b: any): any { 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) { switch (op) {
case "+": case "+":
if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b]; 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)), index: (x) => obj.findIndex((y) => pyEq(y, x)),
count: (x) => obj.filter((y) => pyEq(y, x)).length, count: (x) => obj.filter((y) => pyEq(y, x)).length,
reverse: () => void obj.reverse(), 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), clear: () => void obj.splice(0),
copy: () => [...obj], copy: () => [...obj],
add: (x) => void (contains(obj, x) || obj.push(x)), // sets are lists here 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); return obj.get(k);
}, },
update: (other: PyDict) => other.map.forEach((v, k) => obj.map.set(k, v)), 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(), clear: () => obj.map.clear(),
copy: () => new PyDict(obj.map.entries()), copy: () => new PyDict(obj.map.entries()),
}; };
@@ -341,6 +353,35 @@ function methodOf(obj: any, name: string): any {
}, },
count: (s) => obj.split(s).length - 1, count: (s) => obj.split(s).length - 1,
find: (s) => obj.indexOf(s), 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), isdigit: () => /^\d+$/.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj), isalpha: () => /^\p{L}+$/u.test(obj),
}; };
@@ -412,12 +453,16 @@ export class Python {
const m = methodOf(o, e.a); const m = methodOf(o, e.a);
if (m) return m; if (m) return m;
const v = o instanceof PyDict ? o.get(e.a) : o[e.a]; 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; return v === undefined ? null : v;
} }
case "sub": { case "sub": {
const o = this.eval(e.o); const o = this.eval(e.o);
const i = this.eval(e.i); const i = this.eval(e.i);
if (o instanceof PyDict) { 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)); if (this.strict && !o.has(i)) this.raise("KeyError", pyStr(i));
return o.has(i) ? o.get(i) : null; return o.has(i) ? o.get(i) : null;
} }
+82 -11
View File
@@ -2,6 +2,7 @@
// The stage has the game's native resolution and is scaled with a CSS transform. // The stage has the game's native resolution and is scaled with a CSS transform.
import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
import { ColorMatrix, matrixFilter } from "./colormatrix";
import type { AtSpec, ImageDef, ShownImage, Transition } from "./types"; import type { AtSpec, ImageDef, ShownImage, Transition } from "./types";
const POSITIONS: Record<string, Record<string, number>> = { const POSITIONS: Record<string, Record<string, number>> = {
@@ -93,25 +94,18 @@ export class Renderer {
this.master.insertBefore(el, behind ?? later ?? null); this.master.insertBefore(el, behind ?? later ?? null);
} }
this.shown.set(tag, entry); 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. */ /** Start the ATL timelines of an image; each works on its own copy of the properties. */
private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[]) { private animate(entry: Entry, base: Record<string, any>, specs: AtSpec[], timeline?: AtlBlock) {
if (this.fast) return; if (this.fast) return;
const live = { ...base }; const live = { ...base };
for (const spec of specs) { for (const spec of specs) {
const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl; const block = typeof spec.atl === "string" ? this.host.atl(spec.atl) : spec.atl;
if (!block) continue; if (block) this.startTimeline(entry.el, block, live, bindArguments(block, spec, (e) => this.host.evalExpr(e)), entry.runners);
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 (timeline) this.startTimeline(entry.el, timeline, live, new Map(), entry.runners);
} }
hide(tag: string) { hide(tag: string) {
@@ -149,6 +143,16 @@ export class Renderer {
const ref = this.host.resolveRef(def.ref); const ref = this.host.resolveRef(def.ref);
if (ref) return this.build({ ...ref, props: { ...ref.props, ...def.props } }, depth + 1); 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) { if (def.switch) {
const hit = def.switch.find(([c]) => this.host.evalCond(c)); const hit = def.switch.find(([c]) => this.host.evalCond(c));
if (hit) return this.build(hit[1], depth + 1); 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 }); d.textContent = this.host.text(def.textExpr ?? { k: "const", v: def.text });
return d; return d;
} }
if (def.timeline) return this.timelineBox(def, depth);
if (def.frames?.length) return this.animated(def, depth); if (def.frames?.length) return this.animated(def, depth);
if (def.layered) return this.layered(def, depth); if (def.layered) return this.layered(def, depth);
if (def.file) { if (def.file) {
@@ -210,6 +215,61 @@ export class Renderer {
return document.createElement("div"); 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<string, any>,
vars: Map<string, any>, 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). */ /** An element for an image definition, for UI built outside the scene (screens). */
displayable(def: ImageDef): HTMLElement { displayable(def: ImageDef): HTMLElement {
return this.build(def); return this.build(def);
@@ -322,6 +382,7 @@ export class Renderer {
const filters: string[] = []; const filters: string[] = [];
if (p.filter) filters.push(`${p.filter}(1)`); if (p.filter) filters.push(`${p.filter}(1)`);
if (typeof p.blur === "number" && p.blur > 0) filters.push(`blur(${p.blur}px)`); 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(" "); 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<string, any>) { function normalize(p: Record<string, any>) {
if (p.align) [p.xalign, p.yalign] = p.align; if (p.align) [p.xalign, p.yalign] = p.align;
if (p.pos) [p.xpos, p.ypos] = p.pos; if (p.pos) [p.xpos, p.ypos] = p.pos;
+107 -2
View File
@@ -2,6 +2,7 @@
// whenever game state may have changed. Expressions run in the same Python sandbox as the script. // whenever game state may have changed. Expressions run in the same Python sandbox as the script.
import { AtlRunner, bindArguments, type AtlBlock } from "./atl"; import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix";
import { PyDict, pyStr, truthy, type Python } from "./python"; import { PyDict, pyStr, truthy, type Python } from "./python";
import type { AtSpec, Expr } from "./types"; import type { AtSpec, Expr } from "./types";
@@ -35,6 +36,8 @@ export interface ScreenHost {
mainMenu(): void; mainMenu(): void;
openMenu(): void; openMenu(): void;
openUrl(url: string): void; openUrl(url: string): void;
quickSave(): void;
quickLoad(): void;
setLanguage(code: string | null): void; setLanguage(code: string | null): void;
transform(name: string): Record<string, any> | null; transform(name: string): Record<string, any> | null;
atl(name: string): AtlBlock | null; atl(name: string): AtlBlock | null;
@@ -660,6 +663,68 @@ export class ScreenManager {
const result = typeof a === "function" ? a(0, 0) : null; const result = typeof a === "function" ? a(0, 0) : null;
return this.displayable(Array.isArray(result) ? result[0] : result, depth + 1); 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": { case "transform": {
const child = this.displayable(a, depth + 1); const child = this.displayable(a, depth + 1);
if (!child) return null; if (!child) return null;
@@ -763,6 +828,10 @@ export class ScreenManager {
const paint = () => { const paint = () => {
const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive") const state = !sensitive ? (selected ? "selected_insensitive" : "insensitive")
: selected ? (hover ? "selected_hover" : "selected_idle") : hover ? "hover" : "idle"; : 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; el.dataset.state = state;
if (images) { if (images) {
const file = images[state]; const file = images[state];
@@ -867,6 +936,7 @@ export class ScreenManager {
const style = el.style; const style = el.style;
const alpha = num(g("alpha")); const alpha = num(g("alpha"));
if (alpha !== null) style.opacity = String(alpha); if (alpha !== null) style.opacity = String(alpha);
applyFilters(el, { blur: g("blur"), matrixcolor: g("matrixcolor") });
// size // size
let [width, height] = [g("xsize") ?? g("width"), g("ysize") ?? g("height")]; let [width, height] = [g("xsize") ?? g("width"), g("ysize") ?? g("height")];
const both = g("xysize"); const both = g("xysize");
@@ -979,7 +1049,7 @@ export class ScreenManager {
if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); if (p.rotate) parts.push(`rotate(${p.rotate}deg)`);
el.style.transform = parts.join(" "); el.style.transform = parts.join(" ");
if (p.alpha !== undefined) el.style.opacity = String(p.alpha); 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) { for (const spec of specs) {
Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {}); Object.assign(live, spec.props ?? {}, spec.name ? this.host.transform(spec.name) ?? {} : {});
@@ -1000,6 +1070,8 @@ export class ScreenManager {
apply: (p) => apply(p), apply: (p) => apply(p),
eval: (expr, frame) => this.evalWith(expr, frame), eval: (expr, frame) => this.evalWith(expr, frame),
}, live, bindArguments(block, spec, (e) => this.ev(e))); }, live, bindArguments(block, spec, (e) => this.ev(e)));
runner.watch = el;
((el as any).__atl ??= []).push({ runner, block });
ctx.inst.runners.push(runner); ctx.inst.runners.push(runner);
const inst = ctx.inst; const inst = ctx.inst;
void runner.start(block).then((completed) => { void runner.start(block).then((completed) => {
@@ -1095,7 +1167,10 @@ export class ScreenManager {
this.assign(a, truthy(now) && now !== off ? off : on, owner); this.assign(a, truthy(now) && now !== off ? off : on, owner);
return; return;
} }
case "SetLocalVariable":
case "SetScreenVariable": owner?.vars.set(a, b); return; case "SetScreenVariable": owner?.vars.set(a, b); return;
case "QuickSave": this.host.quickSave(); return;
case "QuickLoad": this.host.quickLoad(); throw new ActionAbort();
case "ToggleScreenVariable": { case "ToggleScreenVariable": {
const now = owner?.vars.get(a); const now = owner?.vars.get(a);
const on = action.args.length > 1 ? b : true; 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", for (const kind of ["Jump", "Call", "Return", "SetVariable", "ToggleVariable", "SetDict", "SetField", "ToggleField",
"SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play", "SetScreenVariable", "ToggleScreenVariable", "Show", "Hide", "ShowTransient", "Function", "NullAction", "Play",
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu", "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 shape = (kind: string) => (...args: any[]) => {
const last = args[args.length - 1]; const last = args[args.length - 1];
const hasKw = last && typeof last === "object" && !Array.isArray(last) && Object.hasOwn(last, "__kw"); 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.Transform = shape("transform");
out.DynamicDisplayable = shape("dynamic"); out.DynamicDisplayable = shape("dynamic");
out.Color = (value: any) => (typeof value === "string" ? value : pyStr(value)); 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.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no);
out.GetTooltip = () => this.tooltip; out.GetTooltip = () => this.tooltip;
out.StaticValue = (value: number, range = 1) => new BarValue(() => value, () => range); 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 { function lengthOf(v: any): string {
return typeof v === "number" ? PX_UNIT(v) : "0px"; return typeof v === "number" ? PX_UNIT(v) : "0px";
} }
+2
View File
@@ -28,6 +28,8 @@ export interface ImageDef {
props?: Record<string, any>; props?: Record<string, any>;
frames?: { img: ImageDef; d?: number }[]; frames?: { img: ImageDef; d?: number }[];
repeat?: boolean; repeat?: boolean;
/** ATL timeline that switches pictures and moves them (image blocks with parallel, contains ...). */
timeline?: import("./atl").AtlBlock;
layered?: Layer[]; layered?: Layer[];
at?: AtSpec[]; at?: AtSpec[];
selected?: string[]; // attributes chosen for a layered image (set by the engine) selected?: string[]; // attributes chosen for a layered image (set by the engine)
+132
View File
@@ -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("<!doctype html><html><body></body></html>");
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<n>\\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);
});