feat: improve RenPy conversion and support MIST runtime
This commit is contained in:
1 parent
f7f7f5b1b3
commit
f9eb5c1212
44 files changed
+2822
-174
No files matched your search
@@ -43,6 +43,8 @@ def renpy_version(root: Path) -> str:
|
|||||||
def analyze(src: Path, dump: Path | None = None) -> dict:
|
def analyze(src: Path, dump: Path | None = None) -> dict:
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
game_dir = find_game_dir(src)
|
game_dir = find_game_dir(src)
|
||||||
|
from app.games.renpy_builds import identify, prepare_source, skipped_files, waive_known
|
||||||
|
build = identify(game_dir)
|
||||||
data = ScriptData()
|
data = ScriptData()
|
||||||
work = Path(tempfile.mkdtemp(prefix="analyze-"))
|
work = Path(tempfile.mkdtemp(prefix="analyze-"))
|
||||||
try:
|
try:
|
||||||
@@ -69,13 +71,18 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
|
|||||||
if dump is not None:
|
if dump is not None:
|
||||||
shutil.copytree(work, dump / src.name, dirs_exist_ok=True,
|
shutil.copytree(work, dump / src.name, dirs_exist_ok=True,
|
||||||
ignore=shutil.ignore_patterns("*.rpyc", "*.rpymc"))
|
ignore=shutil.ignore_patterns("*.rpyc", "*.rpymc"))
|
||||||
rpy = sorted((p for p in work.rglob("*.rpy") if not _is_engine_file(work, p)),
|
skipped = skipped_files(build)
|
||||||
|
rpy = sorted((p for p in work.rglob("*.rpy") if not _is_engine_file(work, p)
|
||||||
|
and p.relative_to(work).as_posix().lower() not in skipped),
|
||||||
key=lambda p: p.relative_to(work).as_posix().lower())
|
key=lambda p: p.relative_to(work).as_posix().lower())
|
||||||
lines = 0
|
lines = 0
|
||||||
|
sources = {p: prepare_source(build, p.relative_to(work).as_posix(), read_source(p)) for p in rpy}
|
||||||
|
for p in rpy:
|
||||||
|
data.prescan(sources[p])
|
||||||
for p in rpy:
|
for p in rpy:
|
||||||
rel = p.relative_to(work).as_posix()
|
rel = p.relative_to(work).as_posix()
|
||||||
try:
|
try:
|
||||||
text = read_source(p)
|
text = sources[p]
|
||||||
lines += text.count("\n")
|
lines += text.count("\n")
|
||||||
RpyParser(data, rel).parse(text)
|
RpyParser(data, rel).parse(text)
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
@@ -85,6 +92,7 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
|
|||||||
|
|
||||||
by_line = locales.assign_ids(data.modules)
|
by_line = locales.assign_ids(data.modules)
|
||||||
audit_ir(data)
|
audit_ir(data)
|
||||||
|
waived = waive_known(data, build)
|
||||||
translations = locales.import_tl(work, data.modules, by_line, data.warn)
|
translations = locales.import_tl(work, data.modules, by_line, data.warn)
|
||||||
says = len(by_line) or 1
|
says = len(by_line) or 1
|
||||||
language = data.config.get("language")
|
language = data.config.get("language")
|
||||||
@@ -109,6 +117,8 @@ def analyze(src: Path, dump: Path | None = None) -> dict:
|
|||||||
for msg, count in data.blockers.items()] +
|
for msg, count in data.blockers.items()] +
|
||||||
[asdict(Issue("archive_error", msg)) for msg in rpa_errors],
|
[asdict(Issue("archive_error", msg)) for msg in rpa_errors],
|
||||||
"game": src.name,
|
"game": src.name,
|
||||||
|
"build": build,
|
||||||
|
"knownDefects": waived,
|
||||||
"renpy": renpy_version(src),
|
"renpy": renpy_version(src),
|
||||||
"seconds": round(time.monotonic() - started, 1),
|
"seconds": round(time.monotonic() - started, 1),
|
||||||
"rpyFiles": len(rpy),
|
"rpyFiles": len(rpy),
|
||||||
|
|||||||
@@ -82,7 +82,9 @@ def find_game_dir(root: Path) -> Path:
|
|||||||
p = Path(dirpath)
|
p = Path(dirpath)
|
||||||
depth = len(p.relative_to(root).parts)
|
depth = len(p.relative_to(root).parts)
|
||||||
score = (0 if p.name.lower() == "game" else 100) + depth
|
score = (0 if p.name.lower() == "game" else 100) + depth
|
||||||
if p.name.lower() in ("renpy", "common") or "renpy" in [x.lower() for x in p.parts[-3:]]:
|
# Only engine internals inside this distribution matter. A collection's
|
||||||
|
# parent directory may itself be named Renpy.
|
||||||
|
if p.name.lower() in ("renpy", "common") or "renpy" in [x.lower() for x in p.relative_to(root).parts]:
|
||||||
continue
|
continue
|
||||||
if best is None or score < best[0]:
|
if best is None or score < best[0]:
|
||||||
best = (score, p)
|
best = (score, p)
|
||||||
|
|||||||
@@ -32,21 +32,50 @@ 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]
|
||||||
|
CALL_LINE = re.compile(r"^[A-Za-z_]\w*\(.*\)$", re.S)
|
||||||
GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\))$", re.S)
|
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
|
LEADING_ZERO = re.compile(r"(?<![\w.])0+(\d)(?![\d.]*[\w.])")
|
||||||
|
SPLIT_SIGN = re.compile(r"^([a-z_]+)(-[\d.]+)$")
|
||||||
|
IGNORED = {"subpixel", "animation", "around", "alignaround", "rotate_pad"} # no visible effect in the browser
|
||||||
# Purely graphical GPU hints; dropping them changes how a picture is drawn, never what happens
|
# 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",
|
COSMETIC = ("shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate",
|
||||||
"zrotate", "zpos", "zzoom", "delay", "events", "fit", "nearest", "fps", "show_cancels_hide")
|
"zrotate", "zpos", "zzoom", "delay", "events", "fit", "nearest", "fps", "show_cancels_hide")
|
||||||
|
NAMED_POSITIONS = {
|
||||||
|
"left": {"xpos": 0.0, "xanchor": 0.0}, "right": {"xpos": 1.0, "xanchor": 1.0},
|
||||||
|
"center": {"xpos": 0.5, "xanchor": 0.5}, "truecenter": {"xpos": 0.5, "xanchor": 0.5, "ypos": 0.5, "yanchor": 0.5},
|
||||||
|
"top": {"xpos": 0.5, "xanchor": 0.5, "ypos": 0.0, "yanchor": 0.0},
|
||||||
|
"topleft": {"xpos": 0.0, "xanchor": 0.0, "ypos": 0.0, "yanchor": 0.0},
|
||||||
|
"topright": {"xpos": 1.0, "xanchor": 1.0, "ypos": 0.0, "yanchor": 0.0},
|
||||||
|
}
|
||||||
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default", "hover", "idle",
|
EVENTS = {"show", "hide", "replace", "replaced", "start", "update", "appear", "default", "hover", "idle",
|
||||||
"selected_hover", "selected_idle", "insensitive", "selected_insensitive", "selected"}
|
"selected_hover", "selected_idle", "insensitive", "selected_insensitive", "selected"}
|
||||||
|
|
||||||
|
|
||||||
|
GLUED_NUMBER = re.compile(r"^(\d+(?:\.\d*)?|\.\d+)([A-Za-z_]\w*)$")
|
||||||
|
|
||||||
|
|
||||||
def tokens(text: str) -> list[str]:
|
def tokens(text: str) -> list[str]:
|
||||||
"""Split on whitespace/commas outside brackets and quotes."""
|
"""Split on whitespace/commas outside brackets and quotes."""
|
||||||
|
words = _split(text)
|
||||||
|
out: list[str] = []
|
||||||
|
for word in words:
|
||||||
|
signed = SPLIT_SIGN.match(word)
|
||||||
|
if signed and signed[1] in POSITION_PROPS:
|
||||||
|
out.extend([signed[1], signed[2]])
|
||||||
|
continue
|
||||||
|
glued = GLUED_NUMBER.match(word)
|
||||||
|
if glued and not re.fullmatch(r"e\d+", glued[2]):
|
||||||
|
out.extend([glued[1], glued[2]])
|
||||||
|
else:
|
||||||
|
out.append(word)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _split(text: str) -> list[str]:
|
||||||
out, cur, depth, quote = [], [], 0, ""
|
out, cur, depth, quote = [], [], 0, ""
|
||||||
for ch in text:
|
for ch in text:
|
||||||
if quote:
|
if quote:
|
||||||
@@ -88,6 +117,11 @@ class AtlParser:
|
|||||||
|
|
||||||
def value(self, raw: str, line: Line) -> Any:
|
def value(self, raw: str, line: Line) -> Any:
|
||||||
"""A literal, or ``{"e": expr}`` for anything computed at run time."""
|
"""A literal, or ``{"e": expr}`` for anything computed at run time."""
|
||||||
|
try:
|
||||||
|
return ast.literal_eval(raw)
|
||||||
|
except (ValueError, SyntaxError):
|
||||||
|
pass
|
||||||
|
raw = LEADING_ZERO.sub(r"\1", raw) # `(0.5, 05)`: Ren'Py reads 05 as 5
|
||||||
try:
|
try:
|
||||||
return ast.literal_eval(raw)
|
return ast.literal_eval(raw)
|
||||||
except (ValueError, SyntaxError):
|
except (ValueError, SyntaxError):
|
||||||
@@ -118,6 +152,10 @@ class AtlParser:
|
|||||||
if glued and (glued[1] in POSITION_PROPS or glued[1] in IGNORED or glued[1].startswith(COSMETIC)):
|
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:]]
|
parts = [*parts[:i], glued[1], glued[2], *parts[i + 1:]]
|
||||||
key = glued[1]
|
key = glued[1]
|
||||||
|
if key in NAMED_POSITIONS:
|
||||||
|
out.update(NAMED_POSITIONS[key])
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
if key in IGNORED or key.startswith(COSMETIC):
|
if key in IGNORED or key.startswith(COSMETIC):
|
||||||
_, i = read_value(parts, i + 1) if i + 1 < len(parts) else ("", i + 1)
|
_, i = read_value(parts, i + 1) if i + 1 < len(parts) else ("", i + 1)
|
||||||
continue
|
continue
|
||||||
@@ -128,9 +166,20 @@ class AtlParser:
|
|||||||
self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
|
self.warn(f"Не поддерживается ATL: у {key} нет значения", line)
|
||||||
return out
|
return out
|
||||||
source, i = read_value(parts, i + 1)
|
source, i = read_value(parts, i + 1)
|
||||||
|
while i < len(parts) and re.match(r"^[+\-*/]\s*[\w.(]", parts[i]): # `xpos edge +250`
|
||||||
|
source += " " + parts[i]
|
||||||
|
i += 1
|
||||||
|
knots = []
|
||||||
|
while i + 1 < len(parts) and parts[i] == "knot": # spline: the value is the end, knots the controls
|
||||||
|
knot, i = read_value(parts, i + 1)
|
||||||
|
knots.append(self.value(knot, line))
|
||||||
value = self.value(source, line)
|
value = self.value(source, line)
|
||||||
if value is None:
|
if value is None or None in knots:
|
||||||
return out
|
return out
|
||||||
|
if knots:
|
||||||
|
out[key] = {"spline": [list(k) if isinstance(k, tuple) else k for k in knots]
|
||||||
|
+ [list(value) if isinstance(value, tuple) else value]}
|
||||||
|
continue
|
||||||
if key == "xycenter" and isinstance(value, (list, tuple)) and len(value) == 2:
|
if key == "xycenter" and isinstance(value, (list, tuple)) and len(value) == 2:
|
||||||
out["xcenter"], out["ycenter"] = value
|
out["xcenter"], out["ycenter"] = value
|
||||||
continue
|
continue
|
||||||
@@ -149,6 +198,9 @@ class AtlParser:
|
|||||||
if not parts:
|
if not parts:
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
|
if split := SPLIT_SIGN.match(parts[0]): # decompiled `xzoom-1`
|
||||||
|
if split[1] in POSITION_PROPS:
|
||||||
|
parts = [split[1], split[2], *parts[1:]]
|
||||||
first = parts[0]
|
first = parts[0]
|
||||||
i += 1
|
i += 1
|
||||||
picture = self.image(line) if self.image and not line.block else None
|
picture = self.image(line) if self.image and not line.block else None
|
||||||
@@ -168,6 +220,15 @@ 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 NAMED_POSITIONS:
|
||||||
|
props = dict(NAMED_POSITIONS[first])
|
||||||
|
if len(parts) > 1: # `truecenter zoom 10.0`
|
||||||
|
props.update(self.props(parts[1:], line))
|
||||||
|
steps.append({"op": "set", "props": props})
|
||||||
|
elif re.fullmatch(r"[A-Za-z_]\w*\[.+\]", first) and len(parts) == 1 and not line.block:
|
||||||
|
seconds = self.number(first, line) # `delays[1]`: a number picked from a list
|
||||||
|
if seconds is not None:
|
||||||
|
steps.append({"op": "pause", "t": seconds})
|
||||||
elif first in POSITION_PROPS or ((g := GLUED.match(first)) and g[1] 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:
|
||||||
@@ -177,14 +238,21 @@ class AtlParser:
|
|||||||
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)
|
||||||
steps.append({"op": "set", "props": known} if known is not None else {"op": "use", "name": first})
|
# not a transform when the game defines a number of that name: then it is a pause
|
||||||
|
steps.append({"op": "set", "props": known} if known is not None
|
||||||
|
else {"op": "use", "name": first, "pause": {"k": "name", "id": first}})
|
||||||
|
elif re.match(r"^[\d.(]", first) and not line.block:
|
||||||
|
# decompiled `pause`: a bare number or arithmetic line is a wait
|
||||||
|
seconds = self.number(line.text.strip(), line)
|
||||||
|
if seconds is not None:
|
||||||
|
steps.append({"op": "pause", "t": seconds})
|
||||||
else:
|
else:
|
||||||
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:
|
def transform_call(self, source: str, line: Line) -> dict[str, Any] | None:
|
||||||
"""`other_transform(args)` inside a transform: run it with these arguments."""
|
"""`other_transform(args)` inside a transform: run it with these arguments."""
|
||||||
if not GLUED.match(source) or source.split("(")[0] in POSITION_PROPS:
|
if not CALL_LINE.match(source) or source.split("(")[0] in POSITION_PROPS:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
call = pyexpr.parse_expr(source)
|
call = pyexpr.parse_expr(source)
|
||||||
@@ -193,7 +261,8 @@ class AtlParser:
|
|||||||
if call["k"] != "call" or call["f"]["k"] != "name":
|
if call["k"] != "call" or call["f"]["k"] != "name":
|
||||||
return None
|
return None
|
||||||
self.on_use(call["f"]["id"])
|
self.on_use(call["f"]["id"])
|
||||||
return {"op": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"]}
|
# `delay(0.5, 2)` may also be a function returning a number of seconds to wait
|
||||||
|
return {"op": "use", "name": call["f"]["id"], "args": call["a"], "kw": call["kw"], "pause": call}
|
||||||
|
|
||||||
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]
|
||||||
|
|||||||
@@ -103,7 +103,22 @@ CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
|||||||
# rollback bookkeeping). They never change the story state.
|
# rollback bookkeeping). They never change the story state.
|
||||||
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
CALLS |= {f"renpy.{channel}.{method}" for channel in ("music", "sound")
|
||||||
for method in ("set_volume", "set_pause", "set_pan")}
|
for method in ("set_volume", "set_pause", "set_pan")}
|
||||||
CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition"}
|
CALLS |= {"renpy.end_replay", "renpy.block_rollback", "renpy.transition", "renpy.choice_for_skipping",
|
||||||
|
"renpy.save", "renpy.load"}
|
||||||
|
# Image prediction and rollback checkpoints only warm caches or mark undo points
|
||||||
|
CALLS |= {"renpy.start_predict", "renpy.stop_predict", "renpy.start_predict_screen",
|
||||||
|
"renpy.stop_predict_screen", "renpy.checkpoint", "gc.get_objects", "gc.collect",
|
||||||
|
"_p", "renpy.translate_string", "renpy.substitute", "renpy.save_persistent", "renpy.mark_label_seen",
|
||||||
|
"renpy.get_game_runtime", "renpy.register_textshader", "ui.adjustment", "divmod", "pow",
|
||||||
|
"achievement.register", "achievement.grant", "achievement.has", "achievement.progress",
|
||||||
|
"achievement.clear", "achievement.sync",
|
||||||
|
"renpy.music.get_pos", "renpy.music.get_duration", "renpy.music.get_pause",
|
||||||
|
*(name for name in ("AttributeError", "SyntaxError", "KeyError", "ValueError", "TypeError",
|
||||||
|
"NotImplementedError", "IndexError", "ZeroDivisionError", "RuntimeError",
|
||||||
|
"StopIteration", "AssertionError", "NameError", "ImportError", "OSError")),
|
||||||
|
"renpy.ui.imagebutton", "renpy.return_statement"}
|
||||||
|
from app.games.renpy_native import NATIVE_CLASSES, NATIVE_METHODS # noqa: E402
|
||||||
|
CALLS |= set(NATIVE_CLASSES)
|
||||||
CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"}
|
CALLS |= {"map", "filter", "hasattr", "getattr", "setattr", "callable", "super"}
|
||||||
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
|
# Screen actions and values the player implements (see frontend/src/player/screens.ts)
|
||||||
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
|
SCREEN_CALLS = set("""Jump Call Return SetVariable ToggleVariable SetDict SetField ToggleField
|
||||||
@@ -114,6 +129,11 @@ DynamicDisplayable Color Dissolve Fade ImageDissolve MoveTransition CropMove Pus
|
|||||||
ComposeTransition With Text SetLocalVariable QuickSave QuickLoad AlphaMask Fixed At
|
ComposeTransition With Text SetLocalVariable QuickSave QuickLoad AlphaMask Fixed At
|
||||||
TintMatrix BrightnessMatrix SaturationMatrix ContrastMatrix InvertMatrix OpacityMatrix HueMatrix
|
TintMatrix BrightnessMatrix SaturationMatrix ContrastMatrix InvertMatrix OpacityMatrix HueMatrix
|
||||||
ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split())
|
ColorizeMatrix IdentityMatrix print Exception BaseException Character""".split())
|
||||||
|
SCREEN_CALLS |= set(NATIVE_CLASSES)
|
||||||
|
SCREEN_CALLS |= {"SetMixer", "ImageReference"}
|
||||||
|
# Screen language helpers for audio, focus and pictures that play (see screens.ts)
|
||||||
|
SCREEN_CALLS |= {"Movie", "MixerValue", "AudioPositionValue", "PauseAudio", "ToggleSetMembership",
|
||||||
|
"GetFocusRect", "ToggleFocus", "XScrollValue", "YScrollValue", "Preference", "Start"}
|
||||||
SCREEN_CALLS |= {"im." + name for name in (
|
SCREEN_CALLS |= {"im." + name for name in (
|
||||||
"MatrixColor Scale FactorScale Flip Crop Grayscale Sepia Alpha Blur Composite Tile Rotozoom "
|
"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.brightness matrix.contrast matrix.saturation matrix.tint matrix.identity matrix.invert "
|
||||||
@@ -133,6 +153,8 @@ CALLS |= SCREEN_CALLS | {"globals", "renpy.variant", "renpy.change_language", "r
|
|||||||
"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 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())
|
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())
|
||||||
|
|
||||||
|
METHODS |= NATIVE_METHODS
|
||||||
|
|
||||||
|
|
||||||
def _bound_names(root) -> tuple[set[str], set[str]]:
|
def _bound_names(root) -> tuple[set[str], set[str]]:
|
||||||
"""Names the game binds itself and the method names of its classes.
|
"""Names the game binds itself and the method names of its classes.
|
||||||
@@ -187,30 +209,110 @@ SCREEN_OPENERS = {"renpy.show_screen", "renpy.call_screen", "renpy.hide_screen",
|
|||||||
"Hide"}
|
"Hide"}
|
||||||
|
|
||||||
|
|
||||||
|
# Labels the engine itself enters
|
||||||
|
ENGINE_ROOTS = {"start", "splashscreen", "before_main_menu", "main_menu", "after_load", "_after_load", "quit",
|
||||||
|
"after_warp", "hide_windows", "_hide_windows", "main_menu_screen", "_main_menu_screen", "game_menu"}
|
||||||
|
|
||||||
|
|
||||||
|
def live_modules(data) -> dict[str, list]:
|
||||||
|
"""The scripts' labels that can run: entered by the engine, or named by reachable code.
|
||||||
|
|
||||||
|
Code nobody can reach (a recovery path for engine save files, a debug menu whose entry was removed)
|
||||||
|
is not audited. Names are matched as jump/call targets and as string constants, and a game that jumps
|
||||||
|
to computed labels is treated as able to reach every label."""
|
||||||
|
from app.converter.pipeline import anchors
|
||||||
|
|
||||||
|
owner: dict[str, tuple[str, dict]] = {}
|
||||||
|
for path, module in data.modules.items():
|
||||||
|
for label in module:
|
||||||
|
owner[label["name"]] = (path, label)
|
||||||
|
for name in anchors([label]):
|
||||||
|
owner.setdefault(name, (path, label))
|
||||||
|
|
||||||
|
def computed(value) -> bool:
|
||||||
|
stack = [value]
|
||||||
|
while stack:
|
||||||
|
item = stack.pop()
|
||||||
|
if isinstance(item, list):
|
||||||
|
stack.extend(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
if item.get("op") in ("jump", "call") and "expr" in item:
|
||||||
|
return True
|
||||||
|
fn = item.get("f", {}) if item.get("k") == "call" else {}
|
||||||
|
if fn.get("k") == "attr" and fn.get("a") in ("jump", "call", "call_in_new_context") and fn["o"].get("k") == "name" and fn["o"]["id"] == "renpy" and not (item["a"] and item["a"][0].get("k") == "const"):
|
||||||
|
return True
|
||||||
|
stack.extend(item.values())
|
||||||
|
return False
|
||||||
|
|
||||||
|
if computed([data.modules, data.init_code, data.defaults, list(data.screens.values())]):
|
||||||
|
return data.modules
|
||||||
|
names = set(owner)
|
||||||
|
seen: set[int] = set()
|
||||||
|
pending = [owner[n][1] for n in ENGINE_ROOTS if n in owner]
|
||||||
|
base = referenced_strings([data.init_code, data.defaults, list(data.screens.values()), data.images]) & names
|
||||||
|
pending += [owner[n][1] for n in base]
|
||||||
|
while pending:
|
||||||
|
label = pending.pop()
|
||||||
|
if id(label) in seen:
|
||||||
|
continue
|
||||||
|
seen.add(id(label))
|
||||||
|
for name in referenced_strings([label["body"]]) & names:
|
||||||
|
pending.append(owner[name][1])
|
||||||
|
stack = [label["body"]]
|
||||||
|
while stack:
|
||||||
|
item = stack.pop()
|
||||||
|
if isinstance(item, list):
|
||||||
|
stack.extend(item)
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
if item.get("op") in ("jump", "call") and item.get("target") in owner:
|
||||||
|
pending.append(owner[item["target"]][1])
|
||||||
|
stack.extend(item.values())
|
||||||
|
return {path: [label for label in module if id(label) in seen] for path, module in data.modules.items()}
|
||||||
|
|
||||||
|
|
||||||
def mark_screen_calls(data) -> None:
|
def mark_screen_calls(data) -> None:
|
||||||
"""`renpy.show_screen("x")` in game code uses screen x, just like `show screen x`."""
|
"""`renpy.show_screen("x")` in game code uses screen x, just like `show screen x`.
|
||||||
|
|
||||||
|
A screen that picks another one at run time (``use expression``) may use any screen whose
|
||||||
|
name the game's Python code lists in a tuple or list (a table of screens)."""
|
||||||
|
dynamic = [False]
|
||||||
|
in_init = [False]
|
||||||
|
live = live_modules(data)
|
||||||
|
|
||||||
|
def use(name):
|
||||||
|
if name in data.screens and name not in data.screen_used:
|
||||||
|
data.use_screen(name)
|
||||||
|
changed.append(name)
|
||||||
|
|
||||||
def visit(value):
|
def visit(value):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
for item in value:
|
for item in value:
|
||||||
visit(item)
|
visit(item)
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
|
if value.get("c") == "use" and "nameExpr" in value and not dynamic[0]:
|
||||||
|
dynamic[0] = True
|
||||||
|
changed.append("dynamic")
|
||||||
|
if dynamic[0] and in_init[0] and value.get("k") in ("tuple", "list") and isinstance(value.get("v"), list):
|
||||||
|
for item in value["v"]: # `("chat_entry", kwargs)`: a table of screen names and their data
|
||||||
|
if isinstance(item, dict) and item.get("k") == "const" and isinstance(item.get("v"), str):
|
||||||
|
use(item["v"])
|
||||||
if value.get("k") == "call" and value.get("a"):
|
if value.get("k") == "call" and value.get("a"):
|
||||||
name = value["f"]["id"] if value["f"].get("k") == "name" else (
|
name = value["f"]["id"] if value["f"].get("k") == "name" else (
|
||||||
"renpy." + value["f"]["a"] if value["f"].get("k") == "attr"
|
"renpy." + value["f"]["a"] if value["f"].get("k") == "attr"
|
||||||
and value["f"]["o"].get("k") == "name" and value["f"]["o"]["id"] == "renpy" else "")
|
and value["f"]["o"].get("k") == "name" and value["f"]["o"]["id"] == "renpy" else "")
|
||||||
first = value["a"][0]
|
first = value["a"][0]
|
||||||
if name in SCREEN_OPENERS and first.get("k") == "const" and isinstance(first["v"], str):
|
if name in SCREEN_OPENERS and first.get("k") == "const" and isinstance(first["v"], str):
|
||||||
if first["v"] in data.screens and first["v"] not in data.screen_used:
|
use(first["v"])
|
||||||
data.use_screen(first["v"])
|
|
||||||
changed.append(first["v"])
|
|
||||||
for child in value.values():
|
for child in value.values():
|
||||||
visit(child)
|
visit(child)
|
||||||
|
|
||||||
changed = [True]
|
changed = [True]
|
||||||
while changed:
|
while changed:
|
||||||
changed.clear()
|
changed.clear()
|
||||||
visit([data.modules, data.init_code, data.defaults,
|
in_init[0] = True
|
||||||
[data.screens[n] for n in data.screen_used if n in data.screens]])
|
visit(data.init_code)
|
||||||
|
in_init[0] = False
|
||||||
|
visit([live, data.defaults, [data.screens[n] for n in data.screen_used if n in data.screens]])
|
||||||
|
|
||||||
|
|
||||||
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"}
|
IMPLICIT_METHODS = {"__init__", "__str__", "__repr__"}
|
||||||
@@ -220,7 +322,8 @@ ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"}
|
|||||||
|
|
||||||
# Calls that make the player wait; Python containing them runs on the player's generator interpreter
|
# Calls that make the player wait; Python containing them runs on the player's generator interpreter
|
||||||
BLOCKING_BUILTINS = {"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement",
|
BLOCKING_BUILTINS = {"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement",
|
||||||
"renpy.call_screen"}
|
"renpy.call_screen", "renpy.movie_cutscene", "ui.interact"}
|
||||||
|
CALLS |= {"renpy.free_memory", "achievement.clear_all", "renpy.image", "ImageReference", "SetMixer"}
|
||||||
CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes",
|
CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes",
|
||||||
"eval", "exec", "Replay", "EndReplay", "renpy.end_replay",
|
"eval", "exec", "Replay", "EndReplay", "renpy.end_replay",
|
||||||
"renpy.get_showing_tags", "renpy.run", "renpy.pop_call",
|
"renpy.get_showing_tags", "renpy.run", "renpy.pop_call",
|
||||||
@@ -256,8 +359,8 @@ def blocking_names(data) -> list[str]:
|
|||||||
if value.get("k") == "call":
|
if value.get("k") == "call":
|
||||||
function = value["f"]
|
function = value["f"]
|
||||||
last = function.get("id") if function.get("k") == "name" else function.get("a")
|
last = function.get("id") if function.get("k") == "name" else function.get("a")
|
||||||
dotted = ("renpy." + function["a"]) if (function.get("k") == "attr" and
|
dotted = (function["o"]["id"] + "." + function["a"]) if (function.get("k") == "attr" and
|
||||||
function["o"].get("k") == "name" and function["o"]["id"] == "renpy") else last
|
function["o"].get("k") == "name") else last
|
||||||
if dotted in waiting or last in known or last in waiting:
|
if dotted in waiting or last in known or last in waiting:
|
||||||
return True
|
return True
|
||||||
stack.extend(value.values())
|
stack.extend(value.values())
|
||||||
@@ -273,7 +376,7 @@ def blocking_names(data) -> list[str]:
|
|||||||
return sorted(found)
|
return sorted(found)
|
||||||
|
|
||||||
|
|
||||||
def reachable_init_code(data, used_screens) -> list:
|
def reachable_init_code(data, used_screens, modules=None) -> list:
|
||||||
"""The part of `init python` that can run: startup statements plus every function, class and
|
"""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).
|
method that something reachable refers to (by name or as an attribute).
|
||||||
|
|
||||||
@@ -297,7 +400,7 @@ def reachable_init_code(data, used_screens) -> list:
|
|||||||
collect(data.init_code)
|
collect(data.init_code)
|
||||||
result: list = list(roots)
|
result: list = list(roots)
|
||||||
taken: set[int] = set()
|
taken: set[int] = set()
|
||||||
queue: list = [roots, list(data.modules.values()), data.defaults, data.images, list(used_screens.values())]
|
queue: list = [roots, list((modules or data.modules).values()), data.defaults, data.images, list(used_screens.values())]
|
||||||
while queue:
|
while queue:
|
||||||
fragment = queue.pop()
|
fragment = queue.pop()
|
||||||
stack = [fragment]
|
stack = [fragment]
|
||||||
@@ -323,8 +426,56 @@ def reachable_init_code(data, used_screens) -> list:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def drop_transform_pauses(data) -> None:
|
||||||
|
"""An ATL line naming a transform may instead be a number the game computes (a pause). Once every
|
||||||
|
transform is known, a name that is one needs no such fallback (and nothing to audit)."""
|
||||||
|
known = set(data.atl) | set(data.transforms)
|
||||||
|
stack: list = [data.modules, data.images, data.atl, data.screens, data.transforms]
|
||||||
|
while stack:
|
||||||
|
value = stack.pop()
|
||||||
|
if isinstance(value, list):
|
||||||
|
stack.extend(value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
if value.get("op") == "use" and "pause" in value and value.get("name") in known:
|
||||||
|
del value["pause"]
|
||||||
|
stack.extend(value.values())
|
||||||
|
|
||||||
|
|
||||||
|
def referenced_names(fragments: list) -> set[str]:
|
||||||
|
"""Every plain name the given IR mentions."""
|
||||||
|
found: set[str] = set()
|
||||||
|
stack: list = list(fragments)
|
||||||
|
while stack:
|
||||||
|
value = stack.pop()
|
||||||
|
if isinstance(value, list):
|
||||||
|
stack.extend(value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
if value.get("k") == "name":
|
||||||
|
found.add(value["id"])
|
||||||
|
stack.extend(value.values())
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def referenced_strings(fragments: list) -> set[str]:
|
||||||
|
"""Every string constant the given IR holds, and the words of a name stored as a list (`show a b`)."""
|
||||||
|
found: set[str] = set()
|
||||||
|
stack: list = list(fragments)
|
||||||
|
while stack:
|
||||||
|
value = stack.pop()
|
||||||
|
if isinstance(value, str):
|
||||||
|
found.add(value)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
if value and all(isinstance(item, str) for item in value):
|
||||||
|
found.add(" ".join(value))
|
||||||
|
stack.extend(value)
|
||||||
|
elif isinstance(value, dict):
|
||||||
|
stack.extend(value.values())
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
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."""
|
||||||
|
drop_transform_pauses(data)
|
||||||
mark_screen_calls(data)
|
mark_screen_calls(data)
|
||||||
used_screens = {n: data.screens[n] for n in data.screen_used if n in data.screens}
|
used_screens = {n: data.screens[n] for n in data.screen_used if n in data.screens}
|
||||||
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images,
|
user_names, user_methods = _bound_names([data.modules, data.init_code, data.defaults, data.images,
|
||||||
@@ -353,9 +504,16 @@ def audit_ir(data) -> None:
|
|||||||
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
|
# `MainMenu(confirm=False)()`: calling the action a screen action constructor returned
|
||||||
if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS:
|
if function.get("k") == "call" and dotted(function.get("f", {})) in SCREEN_CALLS:
|
||||||
is_method = True
|
is_method = True
|
||||||
|
# `globals()[who + "_here"](...)`: a function picked by name; some function must carry that ending
|
||||||
|
if function.get("k") == "sub" and function["o"].get("k") == "call" \
|
||||||
|
and dotted(function["o"]["f"]) == "globals":
|
||||||
|
index = function["i"]
|
||||||
|
suffix = index["r"]["v"] if index.get("k") == "bin" and index.get("op") == "+" \
|
||||||
|
and index["r"].get("k") == "const" else None
|
||||||
|
is_method = isinstance(suffix, str) and any(n.endswith(suffix) for n in user_names)
|
||||||
if name not in CALLS and name not in user_names and not is_method:
|
if name not in CALLS and name not in user_names and not is_method:
|
||||||
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
|
data.warn(f"Вызов не поддерживается плеером: {name or 'динамическая функция'}", location)
|
||||||
if value.get("op") in {"jump", "call"} and value.get("target"):
|
if value.get("op") in ("jump", "call") and isinstance(value.get("target"), str):
|
||||||
if value["target"] not in labels:
|
if value["target"] not in labels:
|
||||||
data.warn(f"Не найдена цель перехода: {value['target']}", location)
|
data.warn(f"Не найдена цель перехода: {value['target']}", location)
|
||||||
for child in value.values():
|
for child in value.values():
|
||||||
@@ -364,12 +522,39 @@ def audit_ir(data) -> None:
|
|||||||
from app.converter.pipeline import anchors
|
from app.converter.pipeline import anchors
|
||||||
labels = {label["name"] for module in data.modules.values() for label in module} | ENGINE_LABELS
|
labels = {label["name"] for module in data.modules.values() for label in module} | ENGINE_LABELS
|
||||||
labels |= {name for module in data.modules.values() for name in anchors(module)}
|
labels |= {name for module in data.modules.values() for name in anchors(module)}
|
||||||
for path, module in data.modules.items():
|
live = live_modules(data)
|
||||||
|
for path, module in live.items():
|
||||||
visit(module, path)
|
visit(module, path)
|
||||||
visit(data.defaults, "default")
|
visit(data.defaults, "default")
|
||||||
for name, definition in used_screens.items():
|
for name, definition in used_screens.items():
|
||||||
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(reachable_init_code(data, used_screens), "init")
|
reachable = reachable_init_code(data, used_screens, live)
|
||||||
|
visit(reachable, "init")
|
||||||
visit(data.images, "image")
|
visit(data.images, "image")
|
||||||
|
if data.deferred_images:
|
||||||
|
# shown by the game itself, or by another picture that is shown (pictures only used by pictures
|
||||||
|
# nobody shows do not count)
|
||||||
|
strings = referenced_strings([live, data.defaults, list(used_screens.values()), reachable])
|
||||||
|
pending = [name for name in data.images if name in strings]
|
||||||
|
done: set[str] = set()
|
||||||
|
while pending:
|
||||||
|
name = pending.pop()
|
||||||
|
if name in done:
|
||||||
|
continue
|
||||||
|
done.add(name)
|
||||||
|
inner = referenced_strings([data.images[name]])
|
||||||
|
strings |= inner
|
||||||
|
pending.extend(n for n in data.images if n in inner and n not in done)
|
||||||
|
for name, problems in data.deferred_images.items():
|
||||||
|
if name in strings:
|
||||||
|
for message, where in problems:
|
||||||
|
data.warn(message, where)
|
||||||
|
if data.deferred:
|
||||||
|
referenced = referenced_names([live, data.defaults, data.images,
|
||||||
|
list(used_screens.values()), reachable])
|
||||||
|
for name, problems in data.deferred.items():
|
||||||
|
if name in referenced:
|
||||||
|
for message, where in problems:
|
||||||
|
data.warn(message, where)
|
||||||
@@ -145,7 +145,7 @@ def import_tl(game_dir: Path, modules: dict[str, list[dict[str, Any]]],
|
|||||||
_read_tl_file(read_source(f), loc, by_line,
|
_read_tl_file(read_source(f), loc, by_line,
|
||||||
by_group)
|
by_group)
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
warn(f"Перевод {lang_dir.name}: ошибка разбора", f"{f.name}:{e.lineno}")
|
warn(f"Перевод {lang_dir.name}: ошибка разбора ({e.msg})", f"{f.name}:{e.lineno}")
|
||||||
if loc.lines or loc.strings:
|
if loc.lines or loc.strings:
|
||||||
result[lang_code(lang_dir.name)] = loc
|
result[lang_code(lang_dir.name)] = loc
|
||||||
if loc.unmatched:
|
if loc.unmatched:
|
||||||
@@ -176,7 +176,10 @@ def _read_tl_file(text: str, loc: LocaleData, by_line, by_group) -> None:
|
|||||||
target = by_line.get(src)
|
target = by_line.get(src)
|
||||||
if target is None:
|
if target is None:
|
||||||
original = _source_comment(line.block, raw_lines)
|
original = _source_comment(line.block, raw_lines)
|
||||||
group = ident.rsplit("_", 1)[0]
|
# Ren'Py appends a numeric suffix when the same dialogue hash repeats.
|
||||||
|
# `start_ab12cd34_1` still belongs to `start`.
|
||||||
|
match = re.fullmatch(r"(.+)_[0-9a-fA-F]{8}(?:_\d+)?", ident)
|
||||||
|
group = match[1] if match else ident.rsplit("_", 1)[0]
|
||||||
for node in by_group.get(group, []):
|
for node in by_group.get(group, []):
|
||||||
if original is not None and node["what"] == original:
|
if original is not None and node["what"] == original:
|
||||||
target = node
|
target = node
|
||||||
@@ -204,10 +207,15 @@ def _source_comment(block: list[Line], raw_lines: list[str]) -> str | None:
|
|||||||
s = raw.strip()
|
s = raw.strip()
|
||||||
if s.startswith("#"):
|
if s.startswith("#"):
|
||||||
c = Cursor(s[1:].strip())
|
c = Cursor(s[1:].strip())
|
||||||
c.dotted() if not (c.peek('"') or c.peek("'")) else None
|
try:
|
||||||
while c.image_part() is not None and not (c.peek('"') or c.peek("'")):
|
c.dotted() if not (c.peek('"') or c.peek("'")) else None
|
||||||
pass
|
while c.image_part() is not None and not (c.peek('"') or c.peek("'")):
|
||||||
text = c.string()
|
pass
|
||||||
|
text = c.string()
|
||||||
|
except ParseError:
|
||||||
|
# Source dialogue comments may span physical lines. They are only a
|
||||||
|
# fallback hint; malformed comments must not discard valid translations.
|
||||||
|
continue
|
||||||
if text is not None:
|
if text is not None:
|
||||||
return text
|
return text
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
return profile.convert(detection.root, work, progress)
|
return profile.convert(detection.root, work, progress)
|
||||||
game_dir = find_game_dir(src_root)
|
game_dir = find_game_dir(src_root)
|
||||||
|
|
||||||
|
from app.games.renpy_builds import identify, prepare_source, skipped_files, waive_known
|
||||||
|
build = identify(game_dir) # before the archives are unpacked and scripts decompiled
|
||||||
rpas = sorted(game_dir.rglob("*.rpa"))
|
rpas = sorted(game_dir.rglob("*.rpa"))
|
||||||
for i, rpa in enumerate(rpas, 1):
|
for i, rpa in enumerate(rpas, 1):
|
||||||
progress(f"Распаковка {rpa.name} ({i}/{len(rpas)})")
|
progress(f"Распаковка {rpa.name} ({i}/{len(rpas)})")
|
||||||
@@ -99,12 +101,17 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
decompiled = decompile_missing(game_dir, data.warn)
|
decompiled = decompile_missing(game_dir, data.warn)
|
||||||
|
|
||||||
progress("Разбор скриптов")
|
progress("Разбор скриптов")
|
||||||
rpy_files = sorted((p for p in game_dir.rglob("*.rpy") if not _is_engine_file(game_dir, p)),
|
skipped = skipped_files(build)
|
||||||
|
rpy_files = sorted((p for p in game_dir.rglob("*.rpy") if not _is_engine_file(game_dir, p)
|
||||||
|
and p.relative_to(game_dir).as_posix().lower() not in skipped),
|
||||||
key=lambda p: p.relative_to(game_dir).as_posix().lower())
|
key=lambda p: p.relative_to(game_dir).as_posix().lower())
|
||||||
|
sources = {p: prepare_source(build, p.relative_to(game_dir).as_posix(), read_source(p)) for p in rpy_files}
|
||||||
|
for p in rpy_files:
|
||||||
|
data.prescan(sources[p])
|
||||||
for p in rpy_files:
|
for p in rpy_files:
|
||||||
rel = p.relative_to(game_dir).as_posix()
|
rel = p.relative_to(game_dir).as_posix()
|
||||||
try:
|
try:
|
||||||
RpyParser(data, rel).parse(read_source(p))
|
RpyParser(data, rel).parse(sources[p])
|
||||||
except ParseError as e:
|
except ParseError as e:
|
||||||
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
|
data.warn(f"Ошибка разбора файла: {e.msg}", f"{e.filename}:{e.lineno}")
|
||||||
except RecursionError:
|
except RecursionError:
|
||||||
@@ -112,6 +119,8 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
|
if not any(lbl["name"] == "start" for labels in data.modules.values() for lbl in labels):
|
||||||
raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
|
raise RuntimeError("В скриптах не найдена метка start — нечего запускать")
|
||||||
audit_ir(data)
|
audit_ir(data)
|
||||||
|
for message in waive_known(data, build):
|
||||||
|
data.warn(f"Известный дефект оригинала: {message}", blocking=False)
|
||||||
require_supported_scripts(data)
|
require_supported_scripts(data)
|
||||||
|
|
||||||
files: dict[str, dict[str, Any]] = {}
|
files: dict[str, dict[str, Any]] = {}
|
||||||
@@ -198,6 +207,7 @@ def convert_archive(archive: Path, filename: str, work: Path, progress: Progress
|
|||||||
"transitions": data.transitions,
|
"transitions": data.transitions,
|
||||||
"audio": audio,
|
"audio": audio,
|
||||||
"channels": data.channels,
|
"channels": data.channels,
|
||||||
|
"build": data.build,
|
||||||
"files": aliases,
|
"files": aliases,
|
||||||
"defaults": data.defaults,
|
"defaults": data.defaults,
|
||||||
"init": data.init_code,
|
"init": data.init_code,
|
||||||
@@ -331,7 +341,7 @@ def _resolve(path: str, aliases: dict[str, str]) -> str | None:
|
|||||||
def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[str]
|
def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[str]
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
img = dict(img)
|
img = dict(img)
|
||||||
for key in ("file", "movie"):
|
for key in ("file", "movie", "movieMask"):
|
||||||
if isinstance(img.get(key), str):
|
if isinstance(img.get(key), str):
|
||||||
hit = _resolve(img[key], aliases)
|
hit = _resolve(img[key], aliases)
|
||||||
if hit is None:
|
if hit is None:
|
||||||
@@ -340,6 +350,8 @@ def _resolve_image(img: dict[str, Any], aliases: dict[str, str], missing: set[st
|
|||||||
img[key] = hit
|
img[key] = hit
|
||||||
if "switch" in img:
|
if "switch" in img:
|
||||||
img["switch"] = [[c, _resolve_image(sub, aliases, missing)] for c, sub in img["switch"]]
|
img["switch"] = [[c, _resolve_image(sub, aliases, missing)] for c, sub in img["switch"]]
|
||||||
|
if "alphaMask" in img:
|
||||||
|
img["alphaMask"] = {key: _resolve_image(sub, aliases, missing) for key, sub in img["alphaMask"].items()}
|
||||||
if "composite" in img:
|
if "composite" in img:
|
||||||
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
|
img["composite"] = [{**layer, "img": _resolve_image(layer["img"], aliases, missing)}
|
||||||
for layer in img["composite"]]
|
for layer in img["composite"]]
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ 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", "re", "copy", "collections",
|
KNOWN_MODULES = {"random", "math", "renpy", "store", "time", "datetime", "re", "copy", "collections",
|
||||||
"itertools", "string", "functools", "operator", "json"}
|
"itertools", "string", "functools", "operator", "json", "gc"}
|
||||||
|
|
||||||
|
|
||||||
class Unsupported(Exception):
|
class Unsupported(Exception):
|
||||||
@@ -36,25 +36,67 @@ def const(v: Any) -> Expr:
|
|||||||
return {"k": "const", "v": v}
|
return {"k": "const", "v": v}
|
||||||
|
|
||||||
|
|
||||||
|
def python2_operators(source: str) -> str:
|
||||||
|
"""Ren'Py 7 games are Python 2: `a <> b` means `a != b`. Quoted text is left alone."""
|
||||||
|
out: list[str] = []
|
||||||
|
quote = ""
|
||||||
|
i = 0
|
||||||
|
while i < len(source):
|
||||||
|
ch = source[i]
|
||||||
|
if quote:
|
||||||
|
out.append(ch)
|
||||||
|
if ch == "\\" and i + 1 < len(source):
|
||||||
|
out.append(source[i + 1])
|
||||||
|
i += 1
|
||||||
|
elif ch == quote:
|
||||||
|
quote = ""
|
||||||
|
elif ch in "\"'":
|
||||||
|
quote = ch
|
||||||
|
out.append(ch)
|
||||||
|
elif source.startswith("<>", i):
|
||||||
|
out.append("!=")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
i += 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def parse_expr(source: str) -> Expr:
|
def parse_expr(source: str) -> Expr:
|
||||||
try:
|
try:
|
||||||
tree = ast.parse(source.strip(), mode="eval")
|
tree = ast.parse(source.strip(), mode="eval")
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
|
try:
|
||||||
|
tree = ast.parse(python2_operators(source).strip(), mode="eval")
|
||||||
|
except SyntaxError:
|
||||||
|
raise Unsupported(f"синтаксическая ошибка в выражении: {source!r}") from e
|
||||||
return expr(tree.body)
|
return expr(tree.body)
|
||||||
|
|
||||||
|
|
||||||
def parse_stmts(source: str) -> list[Stmt]:
|
def parse_stmts(source: str) -> list[Stmt]:
|
||||||
try:
|
try:
|
||||||
tree = ast.parse(_dedent(source))
|
tree = python_ast(source)
|
||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
|
try:
|
||||||
|
tree = python_ast(python2_operators(source))
|
||||||
|
except SyntaxError:
|
||||||
|
raise Unsupported(f"синтаксическая ошибка в python: {e.msg} ({source[:60]!r})") from e
|
||||||
return stmts(tree.body)
|
return stmts(tree.body)
|
||||||
|
|
||||||
|
|
||||||
def _dedent(source: str) -> str:
|
def python_ast(source: str, mode: str = "exec") -> ast.AST:
|
||||||
import textwrap
|
"""Parse an embedded block as Ren'Py does, preserving string contents/columns.
|
||||||
return textwrap.dedent(source)
|
|
||||||
|
Physical continuation lines may start at column zero. Wrapping the indented
|
||||||
|
code avoids dedenting those lines or changing whitespace inside literals.
|
||||||
|
"""
|
||||||
|
first = next((line for line in source.splitlines() if line.strip()), "")
|
||||||
|
indented = mode == "exec" and first.startswith((" ", "\t"))
|
||||||
|
tree = ast.parse("if True:\n" + source if indented else source, mode=mode)
|
||||||
|
if indented:
|
||||||
|
tree.body = tree.body[0].body
|
||||||
|
ast.increment_lineno(tree, -1)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
|
||||||
def literal(source: str) -> Any:
|
def literal(source: str) -> Any:
|
||||||
@@ -164,6 +206,8 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
|||||||
if type(op) not in BIN_OPS:
|
if type(op) not in BIN_OPS:
|
||||||
raise Unsupported(f"оператор {type(op).__name__}")
|
raise Unsupported(f"оператор {type(op).__name__}")
|
||||||
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
|
return [{"op": "aug", "t": target(t), "o": BIN_OPS[type(op)], "v": expr(value)}]
|
||||||
|
case ast.Expr(value=ast.Call(func=func)) if ast.unparse(func).startswith("build."):
|
||||||
|
return [] # distribution settings (build.classify, build.archive ...) have no meaning in the browser
|
||||||
case ast.Expr(value=ast.Constant()):
|
case ast.Expr(value=ast.Constant()):
|
||||||
return [] # docstring / bare literal
|
return [] # docstring / bare literal
|
||||||
case ast.Expr(value=value):
|
case ast.Expr(value=value):
|
||||||
@@ -220,7 +264,7 @@ def stmt(node: ast.stmt) -> list[Stmt]:
|
|||||||
|
|
||||||
# Methods the player can run implicitly. Any other dunder method would silently
|
# Methods the player can run implicitly. Any other dunder method would silently
|
||||||
# never be called, so the converter refuses it instead.
|
# never be called, so the converter refuses it instead.
|
||||||
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__"}
|
SUPPORTED_DUNDERS = {"__init__", "__str__", "__repr__", "__call__", "__eq__"}
|
||||||
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
INACTIVE_VARIANTS = {"gui.variant", "renpy.variant"}
|
||||||
TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning
|
TRANSPARENT_DECORATORS = {"renpy.pure"} # marks a function as side-effect free; no runtime meaning
|
||||||
DECORATORS = {"staticmethod", "classmethod", "property"}
|
DECORATORS = {"staticmethod", "classmethod", "property"}
|
||||||
|
|||||||
@@ -5,35 +5,31 @@ import re
|
|||||||
import warnings
|
import warnings
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.converter.pyexpr import Expr
|
from app.converter.pyexpr import Expr, python_ast
|
||||||
|
|
||||||
Node = dict[str, Any]
|
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", "matrixcolor", "xycenter"}
|
"align", "pos", "anchor", "xysize", "size", "blur", "matrixcolor", "xycenter",
|
||||||
|
"xsize", "ysize", "crop"}
|
||||||
|
|
||||||
|
|
||||||
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
|
||||||
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
_NUMBER_LINE = re.compile(r"^\d+(\.\d*)?$|^\.\d+$")
|
||||||
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
_CALL_LINE = re.compile(r"^([A-Za-z_][\w.]*)\s*\(")
|
||||||
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
_DISPLAYABLE_CALLS = {"Movie", "Solid", "Transform", "Composite", "LiveComposite", "Fixed",
|
||||||
"ConditionSwitch", "Image", "Text", "im.Scale", "im.FactorScale", "im.Flip",
|
"ConditionSwitch", "TransitionConditionSwitch", "Image", "Text", "AlphaMask", "im.Scale", "im.FactorScale", "im.Flip",
|
||||||
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
"im.Composite", "im.Crop", "im.Grayscale", "im.Sepia", "Frame", "Flatten"}
|
||||||
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
_MEDIA_EXTS = {"png", "jpg", "jpeg", "webp", "gif", "bmp", "avif", "ogg", "mp3", "wav", "opus",
|
||||||
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
|
"flac", "webm", "ogv", "mp4", "mkv", "avi", "m4a"}
|
||||||
|
|
||||||
|
|
||||||
def _dedent(source: str) -> str:
|
|
||||||
import textwrap
|
|
||||||
return textwrap.dedent(source)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_py(source: str, mode: str = "exec") -> ast.AST:
|
def _parse_py(source: str, mode: str = "exec") -> ast.AST:
|
||||||
# decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning
|
# decompiled scripts are full of "\{" in plain strings: don't spam SyntaxWarning
|
||||||
with warnings.catch_warnings():
|
with warnings.catch_warnings():
|
||||||
warnings.simplefilter("ignore")
|
warnings.simplefilter("ignore")
|
||||||
return ast.parse(_dedent(source), mode=mode)
|
return python_ast(source, mode=mode)
|
||||||
|
|
||||||
|
|
||||||
def _dotted_name(e: Expr) -> str:
|
def _dotted_name(e: Expr) -> str:
|
||||||
|
|||||||
@@ -101,12 +101,17 @@ def logical_lines(source: str, filename: str = "") -> list[Line]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# decompiled `layeredimage name: tag`: the block still follows
|
||||||
|
LAYERED_HEADER = re.compile(r"^layeredimage\s+[^:]+:\s+\w+$")
|
||||||
|
|
||||||
|
|
||||||
def build_tree(lines: list[Line], filename: str = "") -> list[Line]:
|
def build_tree(lines: list[Line], filename: str = "") -> list[Line]:
|
||||||
root: list[Line] = []
|
root: list[Line] = []
|
||||||
stack: list[tuple[int, list[Line]]] = [(-1, root)]
|
stack: list[tuple[int, list[Line]]] = [(-1, root)]
|
||||||
prev: Line | None = None
|
prev: Line | None = None
|
||||||
for line in lines:
|
for line in lines:
|
||||||
if prev is not None and line.indent > prev.indent and prev.text.endswith(":"):
|
if prev is not None and line.indent > prev.indent and (
|
||||||
|
prev.text.endswith(":") or LAYERED_HEADER.match(prev.text)):
|
||||||
stack.append((line.indent, prev.block))
|
stack.append((line.indent, prev.block))
|
||||||
else:
|
else:
|
||||||
while stack[-1][0] > line.indent:
|
while stack[-1][0] > line.indent:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import ast
|
import ast
|
||||||
import re
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from dataclasses import dataclass, field
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.converter import pyexpr
|
from app.converter import pyexpr
|
||||||
@@ -19,7 +20,6 @@ from app.converter.rpy_helpers import (
|
|||||||
_assign_name,
|
_assign_name,
|
||||||
_atl_props,
|
_atl_props,
|
||||||
_const,
|
_const,
|
||||||
_dedent,
|
|
||||||
_dotted_name,
|
_dotted_name,
|
||||||
_jump_or_call,
|
_jump_or_call,
|
||||||
_num,
|
_num,
|
||||||
@@ -29,6 +29,7 @@ from app.converter.rpy_helpers import (
|
|||||||
)
|
)
|
||||||
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
from app.converter.rpy_lexer import Cursor, Line, ParseError, parse_tree
|
||||||
from app.converter.screens import ScreenParser, referenced_screens, tokens
|
from app.converter.screens import ScreenParser, referenced_screens, tokens
|
||||||
|
from app.games.renpy_native import COMPILED_IMAGE_CLASSES, NATIVE_CLASSES, NATIVE_IMPORTS
|
||||||
|
|
||||||
Node = dict[str, Any]
|
Node = dict[str, Any]
|
||||||
|
|
||||||
@@ -64,6 +65,9 @@ CHANNELS = {"music", "sound", "voice", "audio", "movie"}
|
|||||||
# Callables that look like transforms in `at` but are displayable/transition factories
|
# Callables that look like transforms in `at` but are displayable/transition factories
|
||||||
KNOWN_FACTORIES = {"Position", "Transform", "Shake", "SnoopyShake", "Move", "Dissolve", "Fade", "Pause"}
|
KNOWN_FACTORIES = {"Position", "Transform", "Shake", "SnoopyShake", "Move", "Dissolve", "Fade", "Pause"}
|
||||||
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
|
SKIP_BLOCKS = {"screen", "style", "translate", "testcase"}
|
||||||
|
# Creator-defined statements of the Ren'Py cookbook that the converter implements (see creator_statements)
|
||||||
|
CREATOR_STATEMENTS = {"random", "block"}
|
||||||
|
ENGINE_SETTINGS = {"config", "gui", "build", "preferences", "style", "_preferences"}
|
||||||
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
|
BUILTIN_CHARACTERS = {"narrator", "centered", "vcentered", "extend", "nvl", "adv", "name_only"}
|
||||||
|
|
||||||
|
|
||||||
@@ -72,6 +76,8 @@ class ScriptData:
|
|||||||
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
|
characters: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
images: dict[str, dict[str, Any]] = field(default_factory=dict)
|
images: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
|
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
|
# every `transform name` of the game, known before any file is parsed (files are read in any order)
|
||||||
|
transform_names: set[str] = field(default_factory=set)
|
||||||
atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name
|
atl: dict[str, dict[str, Any]] = field(default_factory=dict) # animated transforms by name
|
||||||
# A transform's unsupported constructs only matter once something uses it (Ren'Py's stock
|
# A transform's unsupported constructs only matter once something uses it (Ren'Py's stock
|
||||||
# screens.rpy defines several that no game ever shows).
|
# screens.rpy defines several that no game ever shows).
|
||||||
@@ -83,6 +89,13 @@ class ScriptData:
|
|||||||
# Like transforms, a screen only matters once something shows it
|
# Like transforms, a screen only matters once something shows it
|
||||||
screen_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
screen_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
||||||
screen_used: set[str] = field(default_factory=set)
|
screen_used: set[str] = field(default_factory=set)
|
||||||
|
statements: set[str] = field(default_factory=set) # creator-defined statements the game registers
|
||||||
|
random_counter: int = 0
|
||||||
|
# image name -> [(message, where)] of pictures the converter could not build; see audit_ir
|
||||||
|
deferred_images: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
||||||
|
_captured: list | None = None
|
||||||
|
# name -> (message, where) of definitions the converter could not translate; see audit_ir
|
||||||
|
deferred: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
||||||
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
|
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
|
||||||
audio: dict[str, str] = field(default_factory=dict)
|
audio: dict[str, str] = field(default_factory=dict)
|
||||||
defaults: list[list[Any]] = field(default_factory=list)
|
defaults: list[list[Any]] = field(default_factory=list)
|
||||||
@@ -90,6 +103,7 @@ class ScriptData:
|
|||||||
gui: dict[str, Any] = field(default_factory=dict)
|
gui: dict[str, Any] = field(default_factory=dict)
|
||||||
channels: dict[str, dict[str, Any]] = field(default_factory=dict)
|
channels: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||||
config: dict[str, Any] = field(default_factory=dict)
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
build: dict[str, Any] = field(default_factory=dict)
|
||||||
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
|
modules: dict[str, list[Node]] = field(default_factory=dict) # file -> labels
|
||||||
warnings: Counter = field(default_factory=Counter)
|
warnings: Counter = field(default_factory=Counter)
|
||||||
blockers: Counter = field(default_factory=Counter)
|
blockers: Counter = field(default_factory=Counter)
|
||||||
@@ -97,6 +111,23 @@ class ScriptData:
|
|||||||
warning_examples: dict[str, str] = field(default_factory=dict)
|
warning_examples: dict[str, str] = field(default_factory=dict)
|
||||||
stats: Counter = field(default_factory=Counter)
|
stats: Counter = field(default_factory=Counter)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def capture(self):
|
||||||
|
"""Collect the warnings raised inside the block instead of recording them: (kind, where, blocking)."""
|
||||||
|
previous, self._captured = self._captured, []
|
||||||
|
try:
|
||||||
|
yield self._captured
|
||||||
|
finally:
|
||||||
|
self._captured = previous
|
||||||
|
|
||||||
|
def prescan(self, source: str) -> None:
|
||||||
|
"""Facts about the whole game that statements need before their own file is parsed: the names
|
||||||
|
of transforms, and the creator-defined statements the game registers."""
|
||||||
|
self.transform_names.update(re.findall(r"^[ ]*transform[ ]+([A-Za-z_]\w*)", source, re.M))
|
||||||
|
for name in re.findall(r"renpy\.register_statement\(\s*[\"'](\w+)[\"']", source):
|
||||||
|
if name in CREATOR_STATEMENTS:
|
||||||
|
self.statements.add(name)
|
||||||
|
|
||||||
def use_screen(self, name: str) -> None:
|
def use_screen(self, name: str) -> None:
|
||||||
"""A screen is shown by the game: whatever it cannot do now blocks the import."""
|
"""A screen is shown by the game: whatever it cannot do now blocks the import."""
|
||||||
self.screen_used.add(name)
|
self.screen_used.add(name)
|
||||||
@@ -109,6 +140,9 @@ class ScriptData:
|
|||||||
self.use_screen(other)
|
self.use_screen(other)
|
||||||
|
|
||||||
def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
|
def warn(self, kind: str, where: str = "", *, blocking: bool = True) -> None:
|
||||||
|
if self._captured is not None:
|
||||||
|
self._captured.append((kind, where, blocking))
|
||||||
|
return
|
||||||
self.warnings[kind] += 1
|
self.warnings[kind] += 1
|
||||||
if blocking:
|
if blocking:
|
||||||
self.blockers[kind] += 1
|
self.blockers[kind] += 1
|
||||||
@@ -164,9 +198,25 @@ class RpyParser:
|
|||||||
tree = parse_tree(source, self.filename)
|
tree = parse_tree(source, self.filename)
|
||||||
labels: list[Node] = []
|
labels: list[Node] = []
|
||||||
current: Node | None = None
|
current: Node | None = None
|
||||||
for line in tree:
|
index = 0
|
||||||
|
while index < len(tree):
|
||||||
|
line = tree[index]
|
||||||
|
index += 1
|
||||||
c = self.cursor(line)
|
c = self.cursor(line)
|
||||||
first = c.peek_word()
|
first = c.peek_word()
|
||||||
|
if first != "label":
|
||||||
|
# statements after a label without a block (`label x`) or before any label; take the whole
|
||||||
|
# run at once so that an `if` / `else` chain stays together
|
||||||
|
run = [line]
|
||||||
|
while index < len(tree) and Cursor(tree[index].text).peek_word() != "label":
|
||||||
|
run.append(tree[index])
|
||||||
|
index += 1
|
||||||
|
nodes = self.block(run, top_level=True)
|
||||||
|
if nodes and current is not None:
|
||||||
|
current["body"].extend(nodes)
|
||||||
|
elif nodes:
|
||||||
|
labels.append(current := {"name": f"_{self.filename}_{line.lineno}", "body": nodes})
|
||||||
|
continue
|
||||||
if first == "label":
|
if first == "label":
|
||||||
name, params = self.label_header(line)
|
name, params = self.label_header(line)
|
||||||
if not name.startswith("."):
|
if not name.startswith("."):
|
||||||
@@ -176,16 +226,9 @@ class RpyParser:
|
|||||||
current["params"] = params
|
current["params"] = params
|
||||||
labels.append(current)
|
labels.append(current)
|
||||||
continue
|
continue
|
||||||
nodes = self.statement(line, top_level=True)
|
|
||||||
if nodes and current is not None:
|
|
||||||
current["body"].extend(nodes)
|
|
||||||
elif nodes:
|
|
||||||
# statements before any label run on fall-through into the first label
|
|
||||||
labels.append(current := {"name": f"_{self.filename}_{line.lineno}",
|
|
||||||
"body": nodes})
|
|
||||||
self.d.modules[self.filename] = labels
|
self.d.modules[self.filename] = labels
|
||||||
|
|
||||||
def block(self, lines: list[Line]) -> list[Node]:
|
def block(self, lines: list[Line], top_level: bool = False) -> list[Node]:
|
||||||
out: list[Node] = []
|
out: list[Node] = []
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(lines):
|
while i < len(lines):
|
||||||
@@ -208,7 +251,7 @@ class RpyParser:
|
|||||||
out.extend(self.block(line.block))
|
out.extend(self.block(line.block))
|
||||||
i += 1
|
i += 1
|
||||||
continue
|
continue
|
||||||
out.extend(self.statement(line))
|
out.extend(self.statement(line, top_level=top_level))
|
||||||
i += 1
|
i += 1
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@@ -271,6 +314,8 @@ class RpyParser:
|
|||||||
c = self.cursor(line)
|
c = self.cursor(line)
|
||||||
word = c.peek_word()
|
word = c.peek_word()
|
||||||
handler = getattr(self, f"st_{word}", None) if word else None
|
handler = getattr(self, f"st_{word}", None) if word else None
|
||||||
|
if word in CREATOR_STATEMENTS and word in self.d.statements:
|
||||||
|
handler = getattr(self, f"cds_{word}")
|
||||||
if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)):
|
if handler and not (word in ("centered", "extend", "narrator", "nvl") and self._is_say(line)):
|
||||||
try:
|
try:
|
||||||
return handler(line) or []
|
return handler(line) or []
|
||||||
@@ -442,7 +487,22 @@ class RpyParser:
|
|||||||
except SyntaxError as e:
|
except SyntaxError as e:
|
||||||
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
|
self.d.warn(f"Python: синтаксическая ошибка ({e.msg})", self.where(line))
|
||||||
return
|
return
|
||||||
|
native = {n.name for n in tree.body if isinstance(n, ast.ClassDef) and n.name in NATIVE_CLASSES}
|
||||||
|
recipe_functions = self.creator_statements(tree)
|
||||||
for node in tree.body:
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.FunctionDef) and node.name in recipe_functions:
|
||||||
|
continue # the parse/next functions of a statement the converter implements itself
|
||||||
|
if self.is_recipe_registration(node):
|
||||||
|
continue
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name in native:
|
||||||
|
continue # the player ships its own implementation (see NATIVE_CLASSES)
|
||||||
|
if isinstance(node, ast.ClassDef) and node.name in COMPILED_IMAGE_CLASSES:
|
||||||
|
# Image declarations using this factory are compiled by displayable_expr.
|
||||||
|
# Its Ren'Py render-API implementation must not execute during browser init.
|
||||||
|
# Calls outside converted images remain unknown and fail the strict audit.
|
||||||
|
continue
|
||||||
|
if native and isinstance(node, ast.Import) and {a.name for a in node.names} <= NATIVE_IMPORTS:
|
||||||
|
continue
|
||||||
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||||
and ast.unparse(node.value.func) == "renpy.music.register_channel"):
|
and ast.unparse(node.value.func) == "renpy.music.register_channel"):
|
||||||
self.register_channel(node.value)
|
self.register_channel(node.value)
|
||||||
@@ -450,7 +510,7 @@ class RpyParser:
|
|||||||
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
||||||
tgt = node.targets[0]
|
tgt = node.targets[0]
|
||||||
name = _assign_name(tgt)
|
name = _assign_name(tgt)
|
||||||
if name and self.define(name, ast.get_source_segment(_dedent(source), node.value)
|
if name and self.define(name, ast.get_source_segment(source, node.value)
|
||||||
or ast.unparse(node.value), line):
|
or ast.unparse(node.value), line):
|
||||||
continue
|
continue
|
||||||
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
if (isinstance(node, ast.Expr) and isinstance(node.value, ast.Call)
|
||||||
@@ -467,7 +527,45 @@ class RpyParser:
|
|||||||
try:
|
try:
|
||||||
self.d.init_code.extend(pyexpr.stmt(node))
|
self.d.init_code.extend(pyexpr.stmt(node))
|
||||||
except Unsupported as e:
|
except Unsupported as e:
|
||||||
self.d.warn(f"init python: {e}", f"{self.filename}:{line.lineno + node.lineno}")
|
where = f"{self.filename}:{line.lineno + node.lineno}"
|
||||||
|
names = self.definition_names(node)
|
||||||
|
if names:
|
||||||
|
# a definition nobody refers to cannot hurt: it blocks only if reachable code names it
|
||||||
|
for name in names:
|
||||||
|
self.d.deferred.setdefault(name, []).append((f"init python: {e}", where))
|
||||||
|
else:
|
||||||
|
self.d.warn(f"init python: {e}", where)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_recipe_registration(node: ast.stmt) -> bool:
|
||||||
|
call = node.value if isinstance(node, ast.Expr) and isinstance(node.value, ast.Call) else None
|
||||||
|
return bool(call and ast.unparse(call.func) == "renpy.register_statement" and call.args
|
||||||
|
and isinstance(call.args[0], ast.Constant) and call.args[0].value in CREATOR_STATEMENTS)
|
||||||
|
|
||||||
|
def creator_statements(self, tree: ast.Module) -> set[str]:
|
||||||
|
"""``renpy.register_statement("random", parse=..., next=...)`` as in the Ren'Py cookbook:
|
||||||
|
the statement is implemented by the converter, so the registration and its functions are dropped.
|
||||||
|
Returns the names of the functions that belonged to such registrations."""
|
||||||
|
functions: set[str] = set()
|
||||||
|
for node in tree.body:
|
||||||
|
if not self.is_recipe_registration(node):
|
||||||
|
continue
|
||||||
|
call = node.value # type: ignore[attr-defined]
|
||||||
|
self.d.statements.add(call.args[0].value)
|
||||||
|
functions.update(k.value.id for k in call.keywords
|
||||||
|
if k.arg in ("parse", "next", "execute", "predict", "lint") and isinstance(k.value, ast.Name))
|
||||||
|
return functions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def definition_names(node: ast.stmt) -> list[str]:
|
||||||
|
"""Names a function, class or import statement binds."""
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.ClassDef)):
|
||||||
|
return [node.name]
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
return [(a.asname or a.name).split(".")[0] for a in node.names]
|
||||||
|
if isinstance(node, ast.ImportFrom):
|
||||||
|
return [a.asname or a.name for a in node.names]
|
||||||
|
return []
|
||||||
|
|
||||||
def register_channel(self, call: ast.Call) -> None:
|
def register_channel(self, call: ast.Call) -> None:
|
||||||
args = list(call.args) + [None] * 3
|
args = list(call.args) + [None] * 3
|
||||||
@@ -500,6 +598,8 @@ class RpyParser:
|
|||||||
break
|
break
|
||||||
value = c.rest()
|
value = c.rest()
|
||||||
if op != "=":
|
if op != "=":
|
||||||
|
if name.partition(".")[0] in ENGINE_SETTINGS:
|
||||||
|
return [] # `define config.search_prefixes += [...]`: engine settings the converter does not model
|
||||||
self.d.warn("define с += не поддерживается", self.where(line))
|
self.d.warn("define с += не поддерживается", self.where(line))
|
||||||
return []
|
return []
|
||||||
self.define(name, value, line)
|
self.define(name, value, line)
|
||||||
@@ -511,7 +611,8 @@ class RpyParser:
|
|||||||
if head in ("config", "gui", "build", "preferences", "style", "_preferences"):
|
if head in ("config", "gui", "build", "preferences", "style", "_preferences"):
|
||||||
if attr:
|
if attr:
|
||||||
try:
|
try:
|
||||||
(self.d.config if head == "config" else self.d.gui)[attr] = pyexpr.literal(value)
|
settings = self.d.config if head == "config" else self.d.build if head == "build" else self.d.gui
|
||||||
|
settings[attr] = pyexpr.literal(value)
|
||||||
except Unsupported:
|
except Unsupported:
|
||||||
pass
|
pass
|
||||||
return True
|
return True
|
||||||
@@ -603,12 +704,19 @@ class RpyParser:
|
|||||||
if not parts:
|
if not parts:
|
||||||
raise c.error("ожидалось имя изображения")
|
raise c.error("ожидалось имя изображения")
|
||||||
name = " ".join(parts)
|
name = " ".join(parts)
|
||||||
if c.char("="):
|
with self.d.capture() as caught:
|
||||||
img = self.displayable(c.rest(), line)
|
if c.char("="):
|
||||||
elif c.char(":"):
|
img = self.displayable(c.rest(), line)
|
||||||
img = self.atl_image(line)
|
elif c.char(":"):
|
||||||
else:
|
img = self.atl_image(line)
|
||||||
raise c.error("ожидалось = или :")
|
else:
|
||||||
|
raise c.error("ожидалось = или :")
|
||||||
|
# a picture the converter cannot build matters only if something shows it (checked in audit_ir)
|
||||||
|
for kind, where, blocking in caught:
|
||||||
|
if blocking:
|
||||||
|
self.d.deferred_images.setdefault(name, []).append((kind, where))
|
||||||
|
else:
|
||||||
|
self.d.warn(kind, where, blocking=False)
|
||||||
if img is not None:
|
if img is not None:
|
||||||
self.d.images[name] = img
|
self.d.images[name] = img
|
||||||
return []
|
return []
|
||||||
@@ -636,8 +744,30 @@ class RpyParser:
|
|||||||
return {"textExpr": args[0]}
|
return {"textExpr": args[0]}
|
||||||
if fname in ("Movie",):
|
if fname in ("Movie",):
|
||||||
play = kw.get("play") or (args[0] if args else None)
|
play = kw.get("play") or (args[0] if args else None)
|
||||||
return {"movie": _const(play)} if play else None
|
if not play:
|
||||||
if fname == "ConditionSwitch":
|
return None
|
||||||
|
if not isinstance(_const(play), str):
|
||||||
|
self.d.warn("Movie: динамическое имя файла не поддерживается", self.where(line))
|
||||||
|
return None
|
||||||
|
movie = {"movie": _const(play)}
|
||||||
|
if "mask" in kw and kw["mask"].get("k") != "const":
|
||||||
|
self.d.warn("Movie: динамическая маска не поддерживается", self.where(line))
|
||||||
|
return None
|
||||||
|
if "mask" in kw and _const(kw["mask"]) is not None:
|
||||||
|
if not isinstance(_const(kw["mask"]), str):
|
||||||
|
self.d.warn("Movie: маска должна быть именем файла", self.where(line))
|
||||||
|
return None
|
||||||
|
movie["movieMask"] = _const(kw["mask"])
|
||||||
|
if "loop" in kw:
|
||||||
|
loop = _const(kw["loop"])
|
||||||
|
if not isinstance(loop, (bool, int)) or loop not in (0, 1):
|
||||||
|
self.d.warn("Movie: динамический режим повтора не поддерживается", self.where(line))
|
||||||
|
return None
|
||||||
|
movie["movieLoop"] = bool(loop)
|
||||||
|
return movie
|
||||||
|
if fname in ("ConditionSwitch", "TransitionConditionSwitch"):
|
||||||
|
if fname == "TransitionConditionSwitch":
|
||||||
|
args = args[1:] # the first argument is the transition between the states
|
||||||
pairs = []
|
pairs = []
|
||||||
for i in range(0, len(args) - 1, 2):
|
for i in range(0, len(args) - 1, 2):
|
||||||
cond = args[i]
|
cond = args[i]
|
||||||
@@ -647,6 +777,10 @@ class RpyParser:
|
|||||||
if sub:
|
if sub:
|
||||||
pairs.append([cexpr, sub])
|
pairs.append([cexpr, sub])
|
||||||
return {"switch": pairs}
|
return {"switch": pairs}
|
||||||
|
if fname == "AlphaMask" and (len(args) >= 2 or {"child", "mask"} <= set(kw)):
|
||||||
|
child = self.lazy_displayable(args[0] if args else kw["child"], line)
|
||||||
|
mask = self.lazy_displayable(args[1] if len(args) > 1 else kw["mask"], line)
|
||||||
|
return {"alphaMask": {"img": child, "mask": mask}} if child and mask else None
|
||||||
if fname in ("Composite", "LiveComposite", "im.Composite") and args:
|
if fname in ("Composite", "LiveComposite", "im.Composite") and args:
|
||||||
size = _const(args[0])
|
size = _const(args[0])
|
||||||
layers = []
|
layers = []
|
||||||
@@ -677,6 +811,14 @@ class RpyParser:
|
|||||||
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
|
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def lazy_displayable(self, e: Expr, line: Line) -> dict[str, Any] | None:
|
||||||
|
"""A picture known now, or (for a computed one) an expression evaluated when it is shown."""
|
||||||
|
if e["k"] == "const" or (e["k"] == "call" and (_dotted_name(e["f"]) in _DISPLAYABLE_CALLS
|
||||||
|
or _dotted_name(e["f"]) == "AlphaMask")):
|
||||||
|
return self.displayable_expr(e, line)
|
||||||
|
self.d.warn("Изображение задано выражением", self.where(line), blocking=False)
|
||||||
|
return {"expr": e}
|
||||||
|
|
||||||
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"}
|
TIMELINE_WORDS = {"function", "time", "contains", "animation", "parallel", "choice", "block", "on"}
|
||||||
@@ -710,6 +852,13 @@ class RpyParser:
|
|||||||
return True
|
return True
|
||||||
if word == "repeat" and child.text.strip() != "repeat":
|
if word == "repeat" and child.text.strip() != "repeat":
|
||||||
return True
|
return True
|
||||||
|
if re.match(r"^[\d.(]", child.text.strip()) and not _NUMBER_LINE.match(child.text.strip()):
|
||||||
|
return True # a computed pause: `0.5*speed`
|
||||||
|
if re.fullmatch(r"[A-Za-z_]\w*\[.+\]", child.text.strip()):
|
||||||
|
return True # a pause read from a list: `delays[1]`
|
||||||
|
if re.fullmatch(r"[A-Za-z_]\w*(\(.*\))?", child.text.strip()) and not self.displayable_line(child) \
|
||||||
|
and word not in ("repeat", "pause"):
|
||||||
|
return True # a transform or a number held by a variable: `wing_flap`, `random_delay(1, 2)`
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def displayable_line(self, child: Line) -> bool:
|
def displayable_line(self, child: Line) -> bool:
|
||||||
@@ -729,7 +878,7 @@ class RpyParser:
|
|||||||
word = Cursor(first.text).peek_word() or ""
|
word = Cursor(first.text).peek_word() or ""
|
||||||
if not (match or text.startswith("(")):
|
if not (match or text.startswith("(")):
|
||||||
return None
|
return None
|
||||||
if match and (self.displayable_line(first) or match.group(1) in self.d.transforms
|
if match and (self.displayable_line(first) or match.group(1) in self.d.transforms or match.group(1) in self.d.transform_names
|
||||||
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
|
or word in self.TIMELINE_WORDS or WARPER.match(word) or word in POSITION_PROPS):
|
||||||
return None
|
return None
|
||||||
expr = self.expr(text, first)
|
expr = self.expr(text, first)
|
||||||
@@ -1035,6 +1184,8 @@ class RpyParser:
|
|||||||
def static_atl(self, line: Line) -> dict[str, Any]:
|
def static_atl(self, line: Line) -> dict[str, Any]:
|
||||||
props = _atl_props(line.text)
|
props = _atl_props(line.text)
|
||||||
first = Cursor(line.text).peek_word()
|
first = Cursor(line.text).peek_word()
|
||||||
|
if first in ("subpixel", "animation") and not line.block:
|
||||||
|
return {} # no visible effect in the browser
|
||||||
if first not in POSITION_PROPS or not props or line.block:
|
if first not in POSITION_PROPS or not props or line.block:
|
||||||
self.d.warn(f"Не поддерживается динамическая ATL-трансформация: {first or line.text}", self.where(line))
|
self.d.warn(f"Не поддерживается динамическая ATL-трансформация: {first or line.text}", self.where(line))
|
||||||
return props
|
return props
|
||||||
@@ -1042,9 +1193,10 @@ class RpyParser:
|
|||||||
# visual statements -------------------------------------------------------------
|
# visual statements -------------------------------------------------------------
|
||||||
|
|
||||||
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
|
def image_spec(self, c: Cursor, line: Line) -> dict[str, Any]:
|
||||||
if c.keyword("expression"):
|
if c.keyword("expression") or c.keyword("image"):
|
||||||
e = self.expr(c.until(_SHOW_KEYWORDS), line)
|
source = c.until(_SHOW_KEYWORDS, ":")
|
||||||
return {"expr": e}
|
e = self.expr(source, line)
|
||||||
|
return {"expr": e, "tag": source}
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
while True:
|
while True:
|
||||||
save = c.pos
|
save = c.pos
|
||||||
@@ -1062,6 +1214,8 @@ class RpyParser:
|
|||||||
def show_clauses(self, c: Cursor, line: Line, node: Node) -> None:
|
def show_clauses(self, c: Cursor, line: Line, node: Node) -> None:
|
||||||
node.setdefault("at", [])
|
node.setdefault("at", [])
|
||||||
node.setdefault("with", None)
|
node.setdefault("with", None)
|
||||||
|
if (node.get("img") or {}).get("tag"):
|
||||||
|
node.setdefault("as", node["img"]["tag"])
|
||||||
while not c.eol():
|
while not c.eol():
|
||||||
if c.keyword("as"):
|
if c.keyword("as"):
|
||||||
node["as"] = c.image_part()
|
node["as"] = c.image_part()
|
||||||
@@ -1241,8 +1395,10 @@ class RpyParser:
|
|||||||
return []
|
return []
|
||||||
return [{"op": "hide_screen", "name": name}] # hiding a screen that does not exist is harmless
|
return [{"op": "hide_screen", "name": name}] # hiding a screen that does not exist is harmless
|
||||||
spec = self.image_spec(c, line)
|
spec = self.image_spec(c, line)
|
||||||
node: Node = {"op": "hide", "tag": spec.get("name", [""])[0]}
|
node: Node = {"op": "hide", "tag": spec.get("tag", spec.get("name", [""])[0])}
|
||||||
self.show_clauses(c, line, node)
|
self.show_clauses(c, line, node)
|
||||||
|
if node.get("as"):
|
||||||
|
node["tag"] = node.pop("as")
|
||||||
node.pop("at", None)
|
node.pop("at", None)
|
||||||
return [node]
|
return [node]
|
||||||
|
|
||||||
@@ -1390,6 +1546,31 @@ class RpyParser:
|
|||||||
branches, _ = self.if_chain([line], 0)
|
branches, _ = self.if_chain([line], 0)
|
||||||
return [{"op": "if", "branches": branches}]
|
return [{"op": "if", "branches": branches}]
|
||||||
|
|
||||||
|
def cds_random(self, line: Line) -> list[Node]:
|
||||||
|
"""``random:`` (cookbook recipe): run one of the statements below, by weight (`weight 2.0 "text"`)."""
|
||||||
|
options: list[tuple[float, list[Node]]] = []
|
||||||
|
for child in line.block:
|
||||||
|
weight, text = 1.0, child.text
|
||||||
|
if match := re.match(r"weight\s+([\d.]+)\s*(.*)$", text, re.S):
|
||||||
|
weight, text = float(match[1]), match[2]
|
||||||
|
options.append((weight, self.block([replace(child, text=text)])))
|
||||||
|
if not options:
|
||||||
|
return []
|
||||||
|
self.d.random_counter += 1
|
||||||
|
var = f"_random_{self.d.random_counter}"
|
||||||
|
total = sum(w for w, _ in options)
|
||||||
|
pick = pyexpr.parse_stmts(f"{var} = renpy.random.random() * {total!r}")
|
||||||
|
branches, bound = [], 0.0
|
||||||
|
for index, (weight, body) in enumerate(options):
|
||||||
|
bound += weight
|
||||||
|
cond = None if index == len(options) - 1 else pyexpr.parse_expr(f"{var} <= {bound!r}")
|
||||||
|
branches.append({"c": cond, "body": body})
|
||||||
|
return [{"op": "exec", "code": pick}, {"op": "if", "branches": branches}]
|
||||||
|
|
||||||
|
def cds_block(self, line: Line) -> list[Node]:
|
||||||
|
"""``block:`` (cookbook recipe): its statements run in place."""
|
||||||
|
return self.block(line.block)
|
||||||
|
|
||||||
def st_window(self, line: Line) -> list[Node]:
|
def st_window(self, line: Line) -> list[Node]:
|
||||||
c = self.cursor(line)
|
c = self.cursor(line)
|
||||||
c.keyword("window")
|
c.keyword("window")
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ Node = dict[str, Any]
|
|||||||
# Calls whose first argument names a screen to open
|
# Calls whose first argument names a screen to open
|
||||||
OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"}
|
OPENERS = {"Show", "ShowTransient", "ToggleScreen", "Hide", "show_screen", "call_screen", "hide_screen"}
|
||||||
|
|
||||||
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button"}
|
CONTAINERS = {"fixed", "frame", "window", "vbox", "hbox", "grid", "vpgrid", "viewport", "button", "side", "nearrect"}
|
||||||
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar"}
|
LEAVES = {"text", "label", "textbutton", "imagebutton", "add", "image", "null", "bar", "vbar"}
|
||||||
POSITIONAL = {"text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2}
|
POSITIONAL = {"side": 1, "text": 1, "label": 1, "textbutton": 1, "add": 1, "image": 1, "grid": 2}
|
||||||
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
|
ATL_WORDS = {"pause", "repeat", "parallel", "choice", "block", "contains", "function", "time"}
|
||||||
|
|
||||||
LAYOUT = set("""xpos ypos pos xanchor yanchor anchor xalign yalign align xcenter ycenter xoffset yoffset
|
LAYOUT = set("""xpos ypos pos xanchor yanchor anchor xalign yalign align xcenter ycenter xoffset yoffset
|
||||||
@@ -41,19 +41,19 @@ yspacing padding xpadding ypadding left_padding right_padding top_padding bottom
|
|||||||
ymargin left_margin right_margin top_margin bottom_margin width height""".split())
|
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 box_wrap_spacing order_reverse transpose rows cols crop size_group
|
||||||
xtile ytile matrixcolor blur""".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
|
||||||
activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range
|
activate_sound focus keysym alt id modal zorder tag layer variant style style_prefix at value range
|
||||||
adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys
|
adjustment changed mousewheel draggable scrollbars xinitial yinitial child_size edgescroll arrowkeys
|
||||||
text_style properties substitute prefix suffix repeat""".split())
|
text_style properties substitute prefix suffix repeat style_group prefer_top preferred_side""".split())
|
||||||
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
|
# Scrolling widgets share state through adjustments; scrollbars are cosmetic in the browser
|
||||||
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
|
IGNORED_PREFIXES = ("scrollbar_", "vscrollbar_", "bar_", "side_", "thumb_", "slider_", "vslider_", "left_gutter",
|
||||||
"right_gutter", "top_gutter", "bottom_gutter")
|
"right_gutter", "top_gutter", "bottom_gutter")
|
||||||
IGNORED = {"xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "draggable",
|
IGNORED = {"base_bar", "subpixel", "roll_forward", "xadjustment", "yadjustment", "pagekeys", "unscrollable", "arrowkeys", "edgescroll", "draggable",
|
||||||
"child_size", "focus", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
|
"child_size", "mouse", "alt", "properties", "substitute", "prefix", "suffix", "keysym"}
|
||||||
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
|
STATE_PREFIXES = ("idle", "hover", "insensitive", "selected", "selected_idle", "selected_hover",
|
||||||
"selected_insensitive")
|
"selected_insensitive")
|
||||||
ALL = LAYOUT | LOOK | TEXT | BEHAVIOUR
|
ALL = LAYOUT | LOOK | TEXT | BEHAVIOUR
|
||||||
@@ -104,6 +104,8 @@ def tokens(text: str) -> list[str]:
|
|||||||
elif ch in ")]}":
|
elif ch in ")]}":
|
||||||
depth -= 1
|
depth -= 1
|
||||||
cur.append(ch)
|
cur.append(ch)
|
||||||
|
if depth == 0:
|
||||||
|
closed = True
|
||||||
elif ch.isspace() and depth == 0:
|
elif ch.isspace() and depth == 0:
|
||||||
if cur:
|
if cur:
|
||||||
out.append("".join(cur))
|
out.append("".join(cur))
|
||||||
@@ -205,6 +207,8 @@ class ScreenParser:
|
|||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
elif first == "transclude":
|
elif first == "transclude":
|
||||||
nodes.append({"c": "transclude"})
|
nodes.append({"c": "transclude"})
|
||||||
|
elif first == "pass":
|
||||||
|
pass
|
||||||
elif first == "has":
|
elif first == "has":
|
||||||
# `has vbox`: the rest of this block goes inside a new container
|
# `has vbox`: the rest of this block goes inside a new container
|
||||||
container = self.widget(line, words[1:])
|
container = self.widget(line, words[1:])
|
||||||
@@ -265,6 +269,8 @@ class ScreenParser:
|
|||||||
return {"c": "for", "t": stmt["t"], "it": stmt["it"], "body": body}
|
return {"c": "for", "t": stmt["t"], "it": stmt["it"], "body": body}
|
||||||
|
|
||||||
def use(self, line: Line, words: list[str]) -> Node | None:
|
def use(self, line: Line, words: list[str]) -> Node | None:
|
||||||
|
if len(words) > 1 and words[1] == "expression":
|
||||||
|
return self.use_expression(line, words)
|
||||||
call = self.expr(" ".join(words[1:]), line)
|
call = self.expr(" ".join(words[1:]), line)
|
||||||
if call is None:
|
if call is None:
|
||||||
return None
|
return None
|
||||||
@@ -279,6 +285,22 @@ class ScreenParser:
|
|||||||
_, node["body"] = self.body(line.block)
|
_, node["body"] = self.body(line.block)
|
||||||
return node
|
return node
|
||||||
|
|
||||||
|
def use_expression(self, line: Line, words: list[str]) -> Node | None:
|
||||||
|
"""``use expression name pass (args)``: the screen is chosen when the screen is drawn."""
|
||||||
|
match = re.fullmatch(r"(.+?)(?:\s+pass\s*(\(.*\)))?", " ".join(words[2:]), re.S)
|
||||||
|
name = self.expr(match.group(1), line) if match else None
|
||||||
|
call = self.expr("_use" + match.group(2), line) if match and match.group(2) else None
|
||||||
|
if name is None or (match.group(2) and (call is None or call["k"] != "call")):
|
||||||
|
self.warn("Не разобран use expression в screen", line)
|
||||||
|
return None
|
||||||
|
node: Node = {"c": "use", "nameExpr": name, "args": call["a"] if call else [],
|
||||||
|
"kw": call["kw"] if call else {}}
|
||||||
|
if call and call.get("ks"):
|
||||||
|
node["ks"] = call["ks"]
|
||||||
|
if line.block:
|
||||||
|
_, node["body"] = self.body(line.block)
|
||||||
|
return node
|
||||||
|
|
||||||
def python(self, line: Line, first: str) -> list[Node]:
|
def python(self, line: Line, first: str) -> list[Node]:
|
||||||
source = line.text.strip()[1:].strip() if first == "$" else line.block_source()
|
source = line.text.strip()[1:].strip() if first == "$" else line.block_source()
|
||||||
try:
|
try:
|
||||||
@@ -384,9 +406,11 @@ class ScreenParser:
|
|||||||
i = 0
|
i = 0
|
||||||
while i < len(words):
|
while i < len(words):
|
||||||
key, glued = words[i], None
|
key, glued = words[i], None
|
||||||
match = GLUED.match(key)
|
match = GLUED.match(key) or re.fullmatch(r"([a-z_]+)([+-]?(?:\d|\.\d).*)", key)
|
||||||
if match and (known_property(match[1]) or match[1] in SCREEN_PROPS):
|
if match and (known_property(match[1]) or match[1] in SCREEN_PROPS):
|
||||||
key, glued = match[1], match[2]
|
key, glued = match[1], match[2]
|
||||||
|
if key == "style_group": # the old name of style_prefix
|
||||||
|
key = "style_prefix"
|
||||||
if known_property(key) or key in SCREEN_PROPS:
|
if known_property(key) or key in SCREEN_PROPS:
|
||||||
pass
|
pass
|
||||||
elif key in ("with", "nopredict"):
|
elif key in ("with", "nopredict"):
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Authoring defects of concrete Ren'Py builds that the importer tolerates.
|
||||||
|
|
||||||
|
Some games ship with mistakes in their own scripts (a ``jump`` to a label nobody wrote, ``show screen``
|
||||||
|
of a screen that does not exist). Ren'Py fails at the same place, so does the PGames player, which stops
|
||||||
|
with an explicit error when execution reaches one. The importer reports such defects as warnings instead
|
||||||
|
of refusing the whole game.
|
||||||
|
|
||||||
|
A build is recognised by a fingerprint of its script files (names and sizes, not the folder name), and the
|
||||||
|
list is exact: a message is waived only up to the recorded count, so a new or more frequent problem
|
||||||
|
(a mod, another version) is still refused. Add an entry only after confirming that the defect exists in the
|
||||||
|
original build - never to make an import pass.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT_SUFFIXES = {".rpy", ".rpyc", ".rpa", ".rpym", ".rpymc"}
|
||||||
|
|
||||||
|
#: fingerprint -> {"id": ..., "defects": {issue message: maximum count}}
|
||||||
|
BUILDS: dict[str, dict] = {
|
||||||
|
"1d8092ca76924015c64c51fb31ff3ed53b4e8d1026a490648abace41a1664840": {
|
||||||
|
"id": "renpy/mist-1.0.3",
|
||||||
|
"verify": {"scripts.rpa": "1d90ea4cd5970e435aaad7aaa11a32d0a6238aeef8f50ba74226709b74322c02"},
|
||||||
|
# All scripts decompile. These targets are referenced but never declared
|
||||||
|
# in the original scripts.rpa. Do not invent replacement story branches.
|
||||||
|
"defects": {
|
||||||
|
"Не найдена цель перехода: robinspent": 1,
|
||||||
|
"Не найдена цель перехода: breakfastdialog": 1,
|
||||||
|
"Не найдена цель перехода: firstdungeontalk": 1,
|
||||||
|
"Не найдена цель перехода: afterrscene09": 1,
|
||||||
|
"Не найден screen": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"f5dd2b7cc181ae6147fe0034b613b186de7cbde81e6dedde913ac12bb77d8c1b": {
|
||||||
|
"id": "renpy/what-a-legend-0.7.05",
|
||||||
|
# jumps to labels the author never wrote, and a screen that is shown but not defined
|
||||||
|
"defects": {
|
||||||
|
"Не найдена цель перехода: bg_oc_village_miller_label": 1,
|
||||||
|
"Не найдена цель перехода: bg_oc_cityhall_windows_label": 1,
|
||||||
|
"Не найдена цель перехода: q_meetlady_annebase_label": 1,
|
||||||
|
"Не найдена цель перехода: q_myrtle_swimstart_label": 2,
|
||||||
|
"Не найдена цель перехода: q_myrtle_swimtwo_label": 1,
|
||||||
|
"Не найдена цель перехода: q_gomira2_kevin_pre_inn_label": 1,
|
||||||
|
"Не найдена цель перехода: q_gomira2_kevin_pre_inn_door_label": 1,
|
||||||
|
"Не найден screen": 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"2654666aff7f5657102caf4ea5a992dc39e54eae76340705109d8c7d95229f12": {
|
||||||
|
"id": "renpy/the-fixer-0.3.6.04",
|
||||||
|
# names and labels the author never defined, and menu conditions that are not valid Python
|
||||||
|
"defects": {
|
||||||
|
"Выражение: синтаксическая ошибка в выражении: 'player.willpower <=40 and >20'": 1,
|
||||||
|
"Выражение: синтаксическая ошибка в выражении: 'player.willpower <= 60 and > 40'": 1,
|
||||||
|
"Выражение: синтаксическая ошибка в выражении: 'player.willpower >= 80 and player.desire > 20 and < 80'": 1,
|
||||||
|
"Выражение: синтаксическая ошибка в выражении: 'player.will > 30 and < 60'": 2,
|
||||||
|
"Вызов не поддерживается плеером: can_trigger_events": 25,
|
||||||
|
"Не найдена цель перехода: main_quest_05_preperation_vars": 2,
|
||||||
|
"Вызов не поддерживается плеером: c.pokenips_check": 1,
|
||||||
|
"Вызов не поддерживается плеером: fix_clothes": 1,
|
||||||
|
"Вызов не поддерживается плеером: time_kill": 1,
|
||||||
|
"Не найдена цель перехода: work_clothing_fasion_quest_reject": 1,
|
||||||
|
"Не найдена цель перехода: commercial_area_mall_screen": 7,
|
||||||
|
"Не найдена цель перехода: commercial_area": 2,
|
||||||
|
"Не найдена цель перехода: time_round_to_hour_random": 2,
|
||||||
|
"Вызов не поддерживается плеером: player.force_cover": 2,
|
||||||
|
"Не найдена цель перехода: whore_street_sex_floor_vag_want": 1,
|
||||||
|
"Не найдена цель перехода: bedroom_screen": 6,
|
||||||
|
"Вызов не поддерживается плеером: haven_room_join_chance": 2,
|
||||||
|
"Не найдена цель перехода: highway_local_screen": 4,
|
||||||
|
"Не найдена цель перехода: highway_local_motel_screen": 2,
|
||||||
|
"Не найдена цель перехода: bathroom_screen": 3,
|
||||||
|
"Не найдена цель перехода: residential_area_screen": 1,
|
||||||
|
"Вызов не поддерживается плеером: anabel_name": 1,
|
||||||
|
"Вызов не поддерживается плеером: player.sex_agree": 3,
|
||||||
|
"Не найдена цель перехода: shared_dorm_bedroom": 1,
|
||||||
|
"Не найден screen": 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"c262e5f78d5e356269fdde0fd03ca7f1b85a33b0f84df684ff3d3f1eef274906": {
|
||||||
|
"id": "renpy/desert-stalker-0.21.4",
|
||||||
|
# Italian keyword replacer that reads its dictionary from the file system (os/io/zip): not run in
|
||||||
|
# the browser, the translated strings themselves are still imported
|
||||||
|
"skip": ["tl/italian/replaceText.rpy"],
|
||||||
|
# Replace only the verified save recovery block. It assigns renpy.load at init;
|
||||||
|
# merely waiving diagnostics would leave a broken loader in the browser.
|
||||||
|
"blocks": {
|
||||||
|
"scripts/02_functions.rpy": {
|
||||||
|
"c2ec5e42c25fdf617855db0e68b5441f6417be757cfd8e94e7d1b6304f0239a9":
|
||||||
|
"init python:\n def restore_load(filename):\n renpy.load(filename)\n",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"defects": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint(game_dir: Path) -> str:
|
||||||
|
"""Stable identity of a build: the relative names and sizes of its script files and archives."""
|
||||||
|
entries = sorted(
|
||||||
|
f"{p.relative_to(game_dir).as_posix().lower()}:{p.stat().st_size}"
|
||||||
|
for p in game_dir.rglob("*") if p.is_file() and p.suffix.lower() in SCRIPT_SUFFIXES
|
||||||
|
and p.relative_to(game_dir).parts[0].lower() not in {"renpy", "common", "cache"})
|
||||||
|
return hashlib.sha256("\n".join(entries).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def waive_known(data, print_id: str | None) -> list[str]:
|
||||||
|
"""Turn the blockers listed for the build into non-blocking warnings. Returns what was waived."""
|
||||||
|
build = next((b for b in BUILDS.values() if b["id"] == print_id), None)
|
||||||
|
if not build:
|
||||||
|
return []
|
||||||
|
waived = []
|
||||||
|
for message, limit in build["defects"].items():
|
||||||
|
if 0 < data.blockers.get(message, 0) <= limit:
|
||||||
|
del data.blockers[message]
|
||||||
|
data.blocker_examples.pop(message, None)
|
||||||
|
waived.append(message)
|
||||||
|
return waived
|
||||||
|
|
||||||
|
|
||||||
|
def skipped_files(build_id: str | None) -> set[str]:
|
||||||
|
"""Scripts of the build that belong to nothing the game runs (a third-party demo left in the folder)."""
|
||||||
|
build = next((b for b in BUILDS.values() if b["id"] == build_id), None)
|
||||||
|
return {name.lower() for name in build.get("skip", ())} if build else set()
|
||||||
|
|
||||||
|
|
||||||
|
def identify(game_dir: Path) -> str | None:
|
||||||
|
"""The id of a known build, or None."""
|
||||||
|
build = BUILDS.get(fingerprint(game_dir))
|
||||||
|
if build:
|
||||||
|
for relative, digest in build.get("verify", {}).items():
|
||||||
|
path = game_dir / relative
|
||||||
|
if not path.is_file():
|
||||||
|
return None
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
if hashlib.file_digest(stream, "sha256").hexdigest() != digest:
|
||||||
|
return None
|
||||||
|
return build["id"] if build else None
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_source(build_id: str | None, relative: str, source: str) -> str:
|
||||||
|
"""Adapt verified engine integration blocks, preserving source line numbers.
|
||||||
|
|
||||||
|
The digest of each complete block must match as well as the build identity.
|
||||||
|
Unknown/modified code is left intact for the normal strict audit.
|
||||||
|
"""
|
||||||
|
from app.converter.rpy_lexer import parse_tree
|
||||||
|
|
||||||
|
build = next((b for b in BUILDS.values() if b["id"] == build_id), {})
|
||||||
|
patches = build.get("blocks", {}).get(relative.lower(), {})
|
||||||
|
if not patches:
|
||||||
|
return source
|
||||||
|
lines = source.splitlines(keepends=True)
|
||||||
|
tree = parse_tree(source)
|
||||||
|
for index in range(len(tree) - 1, -1, -1):
|
||||||
|
start = tree[index].lineno - 1
|
||||||
|
end = tree[index + 1].lineno - 1 if index + 1 < len(tree) else len(lines)
|
||||||
|
block = "".join(lines[start:end])
|
||||||
|
canonical = block.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
replacement = patches.get(hashlib.sha256(canonical.encode()).hexdigest())
|
||||||
|
if replacement is not None:
|
||||||
|
lines[start:end] = [replacement + "\n" * (end - start - replacement.count("\n"))]
|
||||||
|
return "".join(lines)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
"""Third-party Ren'Py libraries that the player implements natively.
|
||||||
|
|
||||||
|
Some community snippets are Python classes written against Ren'Py's render API (``renpy.Displayable``,
|
||||||
|
``renpy.Render``, pygame events). The browser player cannot run that API, so it ships its own class of the
|
||||||
|
same name and the converter drops the game's copy. Everything the converter needs to know about them lives
|
||||||
|
here; add an entry together with the TypeScript implementation (``nativeClasses`` in ``python.ts`` or the
|
||||||
|
displayable in ``screens.ts``), never in the shared converter code.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: class name -> what the native version offers
|
||||||
|
NATIVE_CLASSES: dict[str, dict] = {
|
||||||
|
"Gallery": {"methods": ("button", "image", "display", "make_button", "Action", "unlock", "unlock_image", "condition")},
|
||||||
|
# parallax picture that drifts against the mouse (screens: ``add TrackCursor("bg.png", 20)``)
|
||||||
|
"TrackCursor": {"methods": ()},
|
||||||
|
# Feniksdev's colour picker; the native class keeps the chosen colour and slot, the game draws its own UI
|
||||||
|
# Ren'Py's own MusicRoom, which games subclass; the player ships the playlist logic (musicroom.ts)
|
||||||
|
"MusicRoom": {"methods": ("add", "is_unlocked", "get_playlist", "Play", "Stop", "TogglePause", "Next",
|
||||||
|
"Previous", "RandomPlay", "SetLoop", "ToggleLoop", "SetSingleTrack",
|
||||||
|
"ToggleSingleTrack", "SetShuffle", "ToggleShuffle", "UnlockAll", "LockAll")},
|
||||||
|
"ColorPicker": {"methods": ("set_color", "save_color", "set_saved_color", "get_color", "swap_to_saved_color")},
|
||||||
|
}
|
||||||
|
|
||||||
|
#: modules those classes import for their event handlers; harmless once the class is replaced
|
||||||
|
NATIVE_IMPORTS = {"pygame"}
|
||||||
|
|
||||||
|
# These image factories are lowered to image IR by displayable_expr, rather than
|
||||||
|
# instantiated in Python. Their render-API classes must not execute at startup.
|
||||||
|
COMPILED_IMAGE_CLASSES = {"TransitionConditionSwitch"}
|
||||||
|
|
||||||
|
NATIVE_METHODS = {m for spec in NATIVE_CLASSES.values() for m in spec["methods"]}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Check/import a local collection. Rejected games never get queued.
|
"""Check/import a local collection. Rejected games never get queued.
|
||||||
|
|
||||||
python -m app.import_library /games --check
|
python -m app.import_library /games --check
|
||||||
|
python -m app.import_library /games/Some-Game --check --single
|
||||||
python -m app.import_library /games --import --owner admin
|
python -m app.import_library /games --import --owner admin
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -73,22 +74,26 @@ async def import_one(source: Path, report: dict, username: str | None) -> dict:
|
|||||||
return {"gameId": game_id, "versionId": version_id, "status": "ready" if ok else "failed"}
|
return {"gameId": game_id, "versionId": version_id, "status": "ready" if ok else "failed"}
|
||||||
|
|
||||||
|
|
||||||
async def main():
|
async def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("root", type=Path)
|
parser.add_argument("root", type=Path)
|
||||||
|
parser.add_argument("--single", action="store_true",
|
||||||
|
help="Проверить/импортировать одну игру: папку или архив вместо коллекции")
|
||||||
mode = parser.add_mutually_exclusive_group(required=True)
|
mode = parser.add_mutually_exclusive_group(required=True)
|
||||||
mode.add_argument("--check", action="store_true", help="Только проверка; без изменения библиотеки")
|
mode.add_argument("--check", action="store_true", help="Только проверка; без изменения библиотеки")
|
||||||
mode.add_argument("--import", dest="import_games", action="store_true", help="Импортировать совместимые игры")
|
mode.add_argument("--import", dest="import_games", action="store_true", help="Импортировать совместимые игры")
|
||||||
parser.add_argument("--owner")
|
parser.add_argument("--owner")
|
||||||
parser.add_argument("--output", type=Path, help="Записать полный JSON-отчёт")
|
parser.add_argument("--output", type=Path, help="Записать полный JSON-отчёт")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args(argv)
|
||||||
if not args.root.is_dir():
|
if args.single and not args.root.exists():
|
||||||
|
parser.error("Игра не найдена: " + str(args.root))
|
||||||
|
if not args.single and not args.root.is_dir():
|
||||||
parser.error("root должен быть каталогом коллекции")
|
parser.error("root должен быть каталогом коллекции")
|
||||||
results = []
|
results = []
|
||||||
for source in entries(args.root):
|
for source in ([args.root] if args.single else entries(args.root)):
|
||||||
print(json.dumps({"game": source.name, "status": "checking"}, ensure_ascii=False), flush=True)
|
print(json.dumps({"game": source.name, "status": "checking"}, ensure_ascii=False), flush=True)
|
||||||
report = await asyncio.to_thread(check, source)
|
report = await asyncio.to_thread(check, source)
|
||||||
result = {"game": source.name, **report}
|
result = {**report, "game": source.name}
|
||||||
if args.import_games and report.get("compatible"):
|
if args.import_games and report.get("compatible"):
|
||||||
result.update(await import_one(source, report, args.owner))
|
result.update(await import_one(source, report, args.owner))
|
||||||
results.append(result)
|
results.append(result)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Export original scripts for the optional frontend initialization smoke test.
|
||||||
|
|
||||||
|
Run from backend, after ``app.converter.analyze --dump <dump> <original>``:
|
||||||
|
python -m tests.export_renpy_runtime <original> <dump/game-name> <runtime.json>
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.converter.archive import find_game_dir
|
||||||
|
from app.converter.compatibility import audit_ir, blocking_names, require_supported_scripts
|
||||||
|
from app.converter.pipeline import _is_engine_file, _used_screens, anchors
|
||||||
|
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||||
|
from app.converter.source import read_source
|
||||||
|
from app.games.renpy_builds import identify, prepare_source, skipped_files, waive_known
|
||||||
|
|
||||||
|
|
||||||
|
def export(original: Path, dump: Path, output: Path) -> None:
|
||||||
|
build = identify(find_game_dir(original))
|
||||||
|
data = ScriptData()
|
||||||
|
sources = {p.relative_to(dump).as_posix(): prepare_source(build, p.relative_to(dump).as_posix(), read_source(p))
|
||||||
|
for p in sorted(dump.rglob("*.rpy")) if not _is_engine_file(dump, p)
|
||||||
|
and p.relative_to(dump).as_posix().lower() not in skipped_files(build)}
|
||||||
|
for source in sources.values():
|
||||||
|
data.prescan(source)
|
||||||
|
for relative, source in sources.items():
|
||||||
|
RpyParser(data, relative).parse(source)
|
||||||
|
audit_ir(data)
|
||||||
|
waive_known(data, build)
|
||||||
|
require_supported_scripts(data)
|
||||||
|
game = dict(title=original.name, resolution=data.gui.get("resolution", [1920, 1080]), entry="start",
|
||||||
|
defaults=data.defaults, init=data.init_code, images=data.images, transforms=data.transforms,
|
||||||
|
build=data.build, atl=data.atl, blocking=blocking_names(data), characters=data.characters,
|
||||||
|
channels=data.channels, screens={n: data.screens[n] for n in _used_screens(data)}, styles=data.styles,
|
||||||
|
transitions=data.transitions, audio=data.audio, files={}, ui={}, sourceLang="en", locales={})
|
||||||
|
modules = [dict(file=p, labels=labels, anchors=anchors(labels)) for p, labels in data.modules.items()]
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
output.write_text(json.dumps(dict(game=game, modules=modules), ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("original", type=Path)
|
||||||
|
parser.add_argument("dump", type=Path)
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
export(args.original, args.dump, args.output)
|
||||||
@@ -80,7 +80,7 @@ def test_renpy_only_declarations_are_not_execution_blockers(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("source,reason", [
|
@pytest.mark.parametrize("source,reason", [
|
||||||
('init python:\n def helper():\n yield 1\nlabel start:\n "Hi"', "init python"),
|
('init python:\n def helper():\n yield 1\nlabel start:\n $ helper()', "init python"),
|
||||||
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
|
('label start:\n $ result = renpy.show_displayable("map")', "renpy.show_displayable"),
|
||||||
('label start:\n jump missing', "missing"),
|
('label start:\n jump missing', "missing"),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -58,7 +58,11 @@ def test_if_chain_and_menu():
|
|||||||
|
|
||||||
def test_unsupported_python_is_reported_not_fatal():
|
def test_unsupported_python_is_reported_not_fatal():
|
||||||
d = parse("init python:\n def f():\n yield 1\n x = 1\nlabel start:\n $ y = x\n")
|
d = parse("init python:\n def f():\n yield 1\n x = 1\nlabel start:\n $ y = x\n")
|
||||||
assert d.warnings and d.init_code[0]["op"] == "set"
|
assert d.init_code[0]["op"] == "set" and "f" in d.deferred # reported once something calls it
|
||||||
|
from app.converter.compatibility import audit_ir
|
||||||
|
d = parse("init python:\n def f():\n yield 1\nlabel start:\n $ f()\n")
|
||||||
|
audit_ir(d)
|
||||||
|
assert d.blockers
|
||||||
|
|
||||||
|
|
||||||
def test_user_functions_and_classes_are_interpreted_not_rejected():
|
def test_user_functions_and_classes_are_interpreted_not_rejected():
|
||||||
@@ -90,8 +94,8 @@ def test_unknown_renpy_calls_and_unsupported_class_features_are_still_reported()
|
|||||||
audit_ir(d)
|
audit_ir(d)
|
||||||
messages = " ".join(d.warnings)
|
messages = " ".join(d.warnings)
|
||||||
assert "renpy.no_such_call" in messages and "missing_function" in messages
|
assert "renpy.no_such_call" in messages and "missing_function" in messages
|
||||||
d = parse("init python:\n class A(object):\n def __eq__(self, other):\n return True\n")
|
d = parse("init python:\n class A(object):\n def __lt__(self, other):\n return True\n")
|
||||||
assert any("__eq__" in w for w in d.warnings)
|
assert "__lt__" in d.deferred["A"][0][0]
|
||||||
|
|
||||||
|
|
||||||
def test_full_pipeline(data_dir, tmp_path):
|
def test_full_pipeline(data_dir, tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.converter.compatibility import audit_ir, blocking_names
|
||||||
|
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||||
|
from app.games import renpy_builds
|
||||||
|
from app.import_library import check
|
||||||
|
|
||||||
|
|
||||||
|
def parse(source):
|
||||||
|
data = ScriptData()
|
||||||
|
data.prescan(source)
|
||||||
|
RpyParser(data, "script.rpy").parse(source)
|
||||||
|
audit_ir(data)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def test_mist_engine_calls_are_audited_and_waiting_helpers_propagate():
|
||||||
|
data = parse('init python:\n def video_player():\n renpy.movie_cutscene("[movieplaying]")\n'
|
||||||
|
' def wait_for_key():\n return ui.interact()\n'
|
||||||
|
'label start:\n $ video_player()\n $ result = wait_for_key()\n'
|
||||||
|
' $ renpy.free_memory()\n $ achievement.clear_all()\n return\n')
|
||||||
|
assert not data.blockers
|
||||||
|
assert blocking_names(data) == ["video_player", "wait_for_key"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gallery_and_runtime_thumbnail_declarations_are_supported():
|
||||||
|
data = parse('init python:\n gallery = Gallery()\n gallery.button("art")\n'
|
||||||
|
' gallery.image("art")\n'
|
||||||
|
' renpy.image("thumb", im.Scale(ImageReference("art"), 384, 216))\n'
|
||||||
|
' SetMixer("ambiant", 0.5)()\nlabel start:\n return\n')
|
||||||
|
assert not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_movie_mask_and_loop_survive_conversion_and_alias_resolution():
|
||||||
|
from app.converter.pipeline import _resolve_image
|
||||||
|
|
||||||
|
data = parse('image effect = Movie(play="effect.mpg", mask="mask.mpg", loop=False)\n'
|
||||||
|
'label start:\n show effect\n return\n')
|
||||||
|
assert data.images["effect"] == {"movie": "effect.mpg", "movieMask": "mask.mpg", "movieLoop": False}
|
||||||
|
missing = set()
|
||||||
|
image = _resolve_image(data.images["effect"], {"effect.mpg": "effect.webm", "mask.mpg": "mask.webm"}, missing)
|
||||||
|
assert image == {"movie": "effect.webm", "movieMask": "mask.webm", "movieLoop": False}
|
||||||
|
assert not missing
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_movie_mask_cannot_silently_become_an_opaque_movie():
|
||||||
|
data = parse('image effect = Movie(play="effect.webm", mask=choose_mask())\n'
|
||||||
|
'label start:\n show effect\n return\n')
|
||||||
|
assert any("маска" in message for message in data.blockers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mist_waivers_do_not_hide_new_or_more_frequent_errors():
|
||||||
|
data = parse('label start:\n jump robinspent\n jump robinspent\n'
|
||||||
|
' jump breakfastdialog\n $ unsupported_new_call()\n')
|
||||||
|
waived = renpy_builds.waive_known(data, "renpy/mist-1.0.3")
|
||||||
|
assert waived == ["Не найдена цель перехода: breakfastdialog"]
|
||||||
|
assert data.blockers["Не найдена цель перехода: robinspent"] == 2
|
||||||
|
assert any("unsupported_new_call" in message for message in data.blockers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_content_digest_rejects_same_size_modified_script_archive(tmp_path, monkeypatch):
|
||||||
|
archive = tmp_path / "scripts.rpa"
|
||||||
|
archive.write_bytes(b"original")
|
||||||
|
fingerprint = renpy_builds.fingerprint(tmp_path)
|
||||||
|
monkeypatch.setitem(renpy_builds.BUILDS, fingerprint, {
|
||||||
|
"id": "test/content-pinned", "verify": {"scripts.rpa": hashlib.sha256(b"original").hexdigest()}, "defects": {}})
|
||||||
|
assert renpy_builds.identify(tmp_path) == "test/content-pinned"
|
||||||
|
archive.write_bytes(b"modified")
|
||||||
|
assert renpy_builds.fingerprint(tmp_path) == fingerprint
|
||||||
|
assert renpy_builds.identify(tmp_path) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(not os.environ.get("PGAMES_MIST_ORIGINAL"), reason="Original MIST distribution is local")
|
||||||
|
def test_original_mist_passes_with_only_confirmed_author_defects():
|
||||||
|
report = check(Path(os.environ["PGAMES_MIST_ORIGINAL"]))
|
||||||
|
assert report["compatible"] and report["hasStart"]
|
||||||
|
assert report["build"] == "renpy/mist-1.0.3"
|
||||||
|
assert len(report["knownDefects"]) == 5
|
||||||
|
assert not report.get("issues")
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Generic Ren'Py constructs found in decompiled games (not tied to one game)."""
|
||||||
|
from app.converter.rpy_parser import RpyParser, ScriptData
|
||||||
|
|
||||||
|
|
||||||
|
def parse(source: str) -> ScriptData:
|
||||||
|
data = ScriptData()
|
||||||
|
data.prescan(source)
|
||||||
|
RpyParser(data, "t.rpy").parse(source)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def steps(data: ScriptData, name: str) -> list[dict]:
|
||||||
|
image = data.images[name]
|
||||||
|
return image["timeline"]["steps"] if "timeline" in image else image["at"][-1]["atl"]["steps"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_numbers_and_expressions_are_pauses():
|
||||||
|
data = parse('image a:\n "x.png"\n 0.5*speed\n "y.png"\n 2\n repeat\n')
|
||||||
|
pauses = [s for s in steps(data, "a") if s["op"] == "pause"]
|
||||||
|
assert len(pauses) == 2 and "e" in pauses[0]["t"] and pauses[1]["t"] == 2.0
|
||||||
|
assert not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_glued_number_and_sign_and_leading_zero():
|
||||||
|
data = parse('transform t:\n linear 2yalign 1.0\n xzoom-1\n anchor (0.5, 05)\n')
|
||||||
|
assert not data.blockers
|
||||||
|
ops = data.atl["t"]["steps"]
|
||||||
|
assert ops[0]["props"] == {"yalign": 1.0} and ops[0]["t"] == 2.0
|
||||||
|
assert ops[1]["props"] == {"xzoom": -1} and ops[2]["props"] == {"anchor": [0.5, 5]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_spline_knots_and_named_position():
|
||||||
|
data = parse('transform t:\n truecenter zoom 2.0\n linear 2.0 pos (800, 50) knot (720, 100)\n')
|
||||||
|
assert not data.blockers
|
||||||
|
move = data.atl["t"]["steps"][1]
|
||||||
|
assert move["props"]["pos"] == {"spline": [[720, 100], [800, 50]]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_transform_declared_after_use_is_a_transform():
|
||||||
|
data = parse('image a:\n get_face("x")\n tint()\ntransform tint():\n alpha 0.5\n')
|
||||||
|
assert "expr" in data.images["a"] and not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconvertible_definition_blocks_only_when_used():
|
||||||
|
from app.converter.compatibility import audit_ir
|
||||||
|
|
||||||
|
data = parse('init python:\n import pygame_x\n def used():\n return 1\n$ x = 1\n')
|
||||||
|
audit_ir(data)
|
||||||
|
assert not data.blockers # nothing refers to the import
|
||||||
|
data = parse('label start:\n $ pygame_x.go()\n return\ninit python:\n import pygame_x\n')
|
||||||
|
audit_ir(data)
|
||||||
|
assert data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_use_expression_marks_screens_named_in_tables():
|
||||||
|
from app.converter.compatibility import audit_ir
|
||||||
|
|
||||||
|
source = ('init python:\n rows = [("entry", {})]\n'
|
||||||
|
'screen log():\n for kind, kw in rows:\n use expression kind pass (**kw)\n'
|
||||||
|
'screen entry():\n text "x"\nlabel start:\n show screen log\n return\n')
|
||||||
|
data = parse(source)
|
||||||
|
audit_ir(data)
|
||||||
|
assert "entry" in data.screen_used and not data.blockers
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiline_source_comment_does_not_drop_valid_translation():
|
||||||
|
from app.converter.locales import LocaleData, _read_tl_file
|
||||||
|
|
||||||
|
loc = LocaleData("french")
|
||||||
|
source = ('translate french start_abcd:\n'
|
||||||
|
' # hero "First line\n # second line"\n'
|
||||||
|
' hero "Bonjour"\n'
|
||||||
|
'translate french strings:\n old "Hello"\n new "Salut"\n')
|
||||||
|
_read_tl_file(source, loc, {}, {})
|
||||||
|
assert loc.unmatched == 1 and loc.strings == {"Hello": "Salut"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_translation_dialogue_still_raises():
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.converter.locales import LocaleData, _read_tl_file
|
||||||
|
from app.converter.rpy_lexer import ParseError
|
||||||
|
|
||||||
|
with pytest.raises(ParseError):
|
||||||
|
_read_tl_file('translate french start_abcd:\n hero "Unclosed\n', LocaleData("french"), {}, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_renpy_translation_hash_uses_original_label_for_fallback():
|
||||||
|
from app.converter.locales import LocaleData, _read_tl_file
|
||||||
|
|
||||||
|
loc = LocaleData("french")
|
||||||
|
node = {"what": "Hello", "tid": "stable"}
|
||||||
|
source = 'translate french label_with_underscores_ab12cd34_1:\n # hero "Hello"\n hero "Bonjour"\n'
|
||||||
|
_read_tl_file(source, loc, {}, {"label_with_underscores": [node]})
|
||||||
|
assert loc.lines == {"stable": "Bonjour"} and not loc.unmatched
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_patch_requires_exact_block_and_preserves_following_line_numbers(monkeypatch):
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.games import renpy_builds
|
||||||
|
|
||||||
|
block = "init python:\n renpy.load = broken_loader\n"
|
||||||
|
replacement = "init python:\n pass\n"
|
||||||
|
monkeypatch.setitem(renpy_builds.BUILDS, "test", {
|
||||||
|
"id": "test", "blocks": {"script.rpy": {hashlib.sha256(block.encode()).hexdigest(): replacement}}})
|
||||||
|
source = block + 'label start:\n "Hi"\n'
|
||||||
|
patched = renpy_builds.prepare_source("test", "script.rpy", source)
|
||||||
|
assert patched == replacement + 'label start:\n "Hi"\n'
|
||||||
|
assert renpy_builds.prepare_source(None, "script.rpy", source) == source
|
||||||
|
assert renpy_builds.prepare_source("test", "other.rpy", source) == source
|
||||||
|
modified = source.replace("broken_loader", "other_loader")
|
||||||
|
assert renpy_builds.prepare_source("test", "script.rpy", modified) == modified
|
||||||
|
assert "broken_loader" not in renpy_builds.prepare_source("test", "script.rpy", source.replace("\n", "\r\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_directory_inside_renpy_collection_is_found(tmp_path):
|
||||||
|
from app.converter.archive import find_game_dir
|
||||||
|
|
||||||
|
root = tmp_path / "Renpy" / "Example"
|
||||||
|
game = root / "game"
|
||||||
|
game.mkdir(parents=True)
|
||||||
|
(game / "scripts.rpa").write_bytes(b"archive")
|
||||||
|
assert find_game_dir(root) == game
|
||||||
|
assert find_game_dir(game) == game
|
||||||
|
(root / "renpy/common").mkdir(parents=True)
|
||||||
|
(root / "renpy/common/engine.rpy").write_text("# engine")
|
||||||
|
assert find_game_dir(root) == game
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_keyword_is_expression_with_stable_tag_for_show_and_hide():
|
||||||
|
data = parse('label start:\n show image found.image with dissolve:\n zoom 0.5\n'
|
||||||
|
' hide image found.image with dissolve\n return\n')
|
||||||
|
assert not data.blockers
|
||||||
|
show, hide, _ = data.modules["t.rpy"][0]["body"]
|
||||||
|
assert show["img"]["expr"]["k"] == "attr"
|
||||||
|
assert show["as"] == hide["tag"] == "found.image"
|
||||||
|
assert show["at"] == [{"props": {"zoom": 0.5}}]
|
||||||
|
aliased = parse('label start:\n show expression found.image as popup\n'
|
||||||
|
' hide image found.image as popup\n')
|
||||||
|
show, hide = aliased.modules["t.rpy"][0]["body"]
|
||||||
|
assert show["as"] == hide["tag"] == "popup"
|
||||||
|
|
||||||
|
|
||||||
|
def test_glued_properties_after_calls_and_negative_atl_coordinates():
|
||||||
|
data = parse('transform moving:\n linear 1.0 xpos -400 ypos-200\n'
|
||||||
|
'screen map():\n imagebutton action Jump("end")tooltip "Enter" align.3,.5\n'
|
||||||
|
'label start:\n call screen map\nlabel end:\n return\n')
|
||||||
|
assert not data.blockers
|
||||||
|
assert data.atl["moving"]["steps"][0]["props"] == {"xpos": -400, "ypos": -200}
|
||||||
|
props = data.screens["map"]["body"][0]["props"]
|
||||||
|
assert props["action"]["k"] == "call" and props["tooltip"]["v"] == "Enter"
|
||||||
|
assert [v["v"] for v in props["align"]["v"]] == [0.3, 0.5]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_constants_remain_available_to_gameplay_and_screens():
|
||||||
|
data = parse('define build.release_channel = "Early Access"\nlabel start:\n return\n')
|
||||||
|
assert data.build == {"release_channel": "Early Access"}
|
||||||
|
assert "release_channel" not in data.gui
|
||||||
|
|
||||||
|
|
||||||
|
def test_python_block_with_flush_left_continuations_keeps_code_indentation():
|
||||||
|
source = ('init python:\n registry = {\n "key": {},\n}\n'
|
||||||
|
' marker = 1\n def helper():\n return registry\n'
|
||||||
|
'label start:\n $ helper()\n return\n')
|
||||||
|
data = parse(source)
|
||||||
|
assert not data.blockers
|
||||||
|
assert [s["op"] for s in data.init_code] == ["set", "set", "def"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dedenting_does_not_use_multiline_string_content_as_code_margin():
|
||||||
|
from app.converter.rpy_helpers import _parse_py
|
||||||
|
|
||||||
|
tree = _parse_py(' message = """First\nflush-left content\n last"""\n value = 1\n')
|
||||||
|
assert tree.body[0].value.value == "First\nflush-left content\n last"
|
||||||
|
assert tree.body[1].targets[0].id == "value"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_python_indentation_still_blocks():
|
||||||
|
data = parse('init python:\n x = 1\n y = 2\nlabel start:\n return\n')
|
||||||
|
assert any("unexpected indent" in message for message in data.blockers)
|
||||||
|
|
||||||
|
|
||||||
|
def test_compiled_image_factory_class_does_not_enter_python_init():
|
||||||
|
from app.converter.compatibility import audit_ir
|
||||||
|
|
||||||
|
source = ('init python:\n class TransitionConditionSwitch(renpy.Displayable):\n'
|
||||||
|
' def render(self):\n return renpy.render(self)\n'
|
||||||
|
'image bg = TransitionConditionSwitch(Dissolve(1), "True", "bg.png")\n'
|
||||||
|
'label start:\n scene bg\n return\n')
|
||||||
|
data = parse(source)
|
||||||
|
audit_ir(data)
|
||||||
|
assert not data.init_code and not data.blockers
|
||||||
|
assert data.images["bg"]["switch"][0][0] == {"k": "const", "v": True}
|
||||||
|
data = parse(source + 'label other:\n $ x = TransitionConditionSwitch(None)\n return\n'
|
||||||
|
'label splashscreen:\n jump other\n')
|
||||||
|
audit_ir(data)
|
||||||
|
assert any("TransitionConditionSwitch" in message for message in data.blockers)
|
||||||
@@ -89,9 +89,9 @@ def test_shown_screens_are_audited_but_unshown_ones_are_not():
|
|||||||
|
|
||||||
|
|
||||||
def test_unknown_actions_and_screens_block_the_import():
|
def test_unknown_actions_and_screens_block_the_import():
|
||||||
data = parse('screen s():\n textbutton "x" action Preference("fullscreen")\nlabel start:\n call screen s\n')
|
data = parse('screen s():\n textbutton "x" action UnsupportedAction("fullscreen")\nlabel start:\n call screen s\n')
|
||||||
audit_ir(data)
|
audit_ir(data)
|
||||||
with pytest.raises(CompatibilityError, match="Preference"):
|
with pytest.raises(CompatibilityError, match="UnsupportedAction"):
|
||||||
require_supported_scripts(data)
|
require_supported_scripts(data)
|
||||||
data = parse('label start:\n call screen nowhere\n')
|
data = parse('label start:\n call screen nowhere\n')
|
||||||
audit_ir(data)
|
audit_ir(data)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.import_library import main
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("archived", [False, True])
|
||||||
|
def test_single_checks_distribution_without_treating_contents_as_games(tmp_path, archived):
|
||||||
|
source = tmp_path / "One"
|
||||||
|
(source / "game").mkdir(parents=True)
|
||||||
|
(source / "game/script.rpy").write_text('label start:\n "Hello"\n return\n')
|
||||||
|
if archived:
|
||||||
|
archive = tmp_path / "One.zip"
|
||||||
|
with zipfile.ZipFile(archive, "w") as handle:
|
||||||
|
handle.write(source / "game/script.rpy", "One/game/script.rpy")
|
||||||
|
source = archive
|
||||||
|
output = tmp_path / "report.json"
|
||||||
|
asyncio.run(main([str(source), "--single", "--check", "--output", str(output)]))
|
||||||
|
results = json.loads(output.read_text(encoding="utf-8"))
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["game"] == source.name
|
||||||
|
assert results[0]["compatible"] is True
|
||||||
|
assert "versionId" not in results[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_single_rejection_writes_report_and_returns_failure(tmp_path):
|
||||||
|
source = tmp_path / "game"
|
||||||
|
source.mkdir()
|
||||||
|
(source / "script.rpy").write_text("label start:\n jump missing\n")
|
||||||
|
output = tmp_path / "report.json"
|
||||||
|
with pytest.raises(SystemExit) as error:
|
||||||
|
asyncio.run(main([str(source), "--single", "--check", "--output", str(output)]))
|
||||||
|
assert error.value.code == 2
|
||||||
|
assert json.loads(output.read_text(encoding="utf-8"))[0]["compatible"] is False
|
||||||
@@ -85,17 +85,17 @@ def test_po_and_csv_roundtrip(manifest):
|
|||||||
def test_entries_page_locates_a_line_in_the_filtered_list(manifest):
|
def test_entries_page_locates_a_line_in_the_filtered_list(manifest):
|
||||||
idx = tr.text_index(manifest)
|
idx = tr.text_index(manifest)
|
||||||
first, second = idx.lines[0], idx.lines[1]
|
first, second = idx.lines[0], idx.lines[1]
|
||||||
page = tr.entries_page(manifest, "ru", state="untranslated", limit=1, locate=second.tid)
|
page = tr.entries_page(manifest, "de", state="untranslated", limit=1, locate=second.tid)
|
||||||
assert page["located"] == 1 and page["total"] == len(idx.lines) and len(page["items"]) == 1
|
assert page["located"] == 1 and page["total"] == len(idx.lines) and len(page["items"]) == 1
|
||||||
|
|
||||||
m = tr.apply_changes(manifest, "ru", lines={first.tid: {"t": "Привет", "state": "machine"}})
|
m = tr.apply_changes(manifest, "de", lines={first.tid: {"t": "Hallo", "state": "machine"}})
|
||||||
# a translated line leaves the "untranslated" list, so the next one moves up
|
# a translated line leaves the "untranslated" list, so the next one moves up
|
||||||
assert tr.entries_page(m, "ru", state="untranslated", locate=second.tid)["located"] == 0
|
assert tr.entries_page(m, "de", state="untranslated", locate=second.tid)["located"] == 0
|
||||||
assert tr.entries_page(m, "ru", state="untranslated", locate=first.tid)["located"] is None
|
assert tr.entries_page(m, "de", state="untranslated", locate=first.tid)["located"] is None
|
||||||
# ...but stays in "todo" because machine translations still await a review
|
# ...but stays in "todo" because machine translations still await a review
|
||||||
assert tr.entries_page(m, "ru", state="todo", locate=first.tid)["located"] == 0
|
assert tr.entries_page(m, "de", state="todo", locate=first.tid)["located"] == 0
|
||||||
assert tr.entries_page(m, "ru", locate="")["located"] is None
|
assert tr.entries_page(m, "de", locate="")["located"] is None
|
||||||
assert tr.entries_page(m, "ru", kind="strings", locate="Эйлин")["located"] is not None
|
assert tr.entries_page(m, "de", kind="strings", locate="Эйлин")["located"] is not None
|
||||||
|
|
||||||
|
|
||||||
def _mark(text):
|
def _mark(text):
|
||||||
|
|||||||
@@ -40,6 +40,46 @@ python -m app.import_library "D:\Torrents\Pgames" --check --output report.json
|
|||||||
python -m app.import_library "D:\Torrents\Pgames" --import --owner admin --output report.json
|
python -m app.import_library "D:\Torrents\Pgames" --import --owner admin --output report.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Для одной игры укажите `--single`: путь должен вести к её папке или архиву.
|
||||||
|
Проверяются сценарии и совместимость тем же способом, что при импорте:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.import_library "D:\Torrents\Pgames\Renpy\DesertStalker-0.21.4-pc" --single --check --output desert-stalker.json
|
||||||
|
python -m app.import_library "D:\Games\SomeGame.zip" --single --check --output game.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Для подробного разбора Ren'Py и сохранения извлечённых/декомпилированных
|
||||||
|
сценариев доступна диагностическая команда (без конвертации медиа):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m app.converter.analyze --dump ..\data-test\renpy-dump "D:\Torrents\Pgames\Renpy\DesertStalker-0.21.4-pc"
|
||||||
|
```
|
||||||
|
|
||||||
|
Приоритет — общая поддержка синтаксиса и поведения Ren'Py в конвертере и
|
||||||
|
плеере. Патчи отдельных сборок находятся в `app/games/renpy_builds.py`;
|
||||||
|
замены блоков дополнительно проверяют SHA-256 исходного текста. Неизвестные
|
||||||
|
изменения проходят обычную строгую проверку. В DesertStalker 0.21.4 блок
|
||||||
|
восстановления нативных сохранений заменяется вызовом загрузчика PGames;
|
||||||
|
сохранения Ren'Py в формате pickle не переносятся.
|
||||||
|
|
||||||
|
Начальный код оригинальной игры можно дополнительно проверить в браузерном
|
||||||
|
интерпретаторе без конвертации медиа. После создания дампа выполните из `backend`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python -m tests.export_renpy_runtime "D:\Torrents\Pgames\Renpy\DesertStalker-0.21.4-pc" "..\data-test\renpy-dump\DesertStalker-0.21.4-pc" "..\data-test\desert-runtime.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем из `frontend`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PGAMES_RENPY_RUNTIME = "D:\Github\pgames-manager\data-test\desert-runtime.json"
|
||||||
|
node --test tests/renpy-original.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Это проверка `init`/`default` и сохранности загрузчика PGames; прохождение
|
||||||
|
сюжета, визуальные эффекты и все ветки она не подтверждает. Оригинальные
|
||||||
|
сценарии и дампы не включаются в репозиторий.
|
||||||
|
|
||||||
`--check` не создаёт карточек или версий и не изменяет исходные игры.
|
`--check` не создаёт карточек или версий и не изменяет исходные игры.
|
||||||
`--import` создаёт версии только для прошедших проверку игр. Обработка
|
`--import` создаёт версии только для прошедших проверку игр. Обработка
|
||||||
коллекции продолжается после отказа отдельной игры. При наличии хотя бы одного
|
коллекции продолжается после отказа отдельной игры. При наличии хотя бы одного
|
||||||
@@ -52,6 +92,36 @@ python -m app.import_library "D:\Torrents\Pgames" --import --owner admin --outpu
|
|||||||
Входные каталоги используются только для чтения; временная распаковка и
|
Входные каталоги используются только для чтения; временная распаковка и
|
||||||
декомпиляция происходят отдельно.
|
декомпиляция происходят отдельно.
|
||||||
|
|
||||||
|
## MIST 1.0.3 и общая поддержка Ren'Py
|
||||||
|
|
||||||
|
Оригинальная сборка MIST 1.0.3 проходит проверку сценариев и полную конвертацию:
|
||||||
|
3527 изображений, 355 аудиофайлов, 778 видео и 5 шрифтов. Общий плеер поддерживает
|
||||||
|
циклы `ui.interact()` с результатами `Return` от уже показанных экранов,
|
||||||
|
`renpy.movie_cutscene` с ожиданием/пропуском, регистрацию изображений через
|
||||||
|
`renpy.image`/`ImageReference`, пользовательские микшеры и базовую `Gallery`.
|
||||||
|
У видео `Movie` сохраняются маски прозрачности и режим повтора; красный канал
|
||||||
|
маски задаёт прозрачность кадра. Эти возможности применяются ко всем играм.
|
||||||
|
|
||||||
|
На исходных сценариях MIST проверены начальный код, победа/поражение QTE,
|
||||||
|
мини-игра поднятия руки и продолжение её цикла после загрузки сохранения.
|
||||||
|
Тесты `frontend/tests/mist-runtime.test.mjs` включаются переменной
|
||||||
|
`PGAMES_MIST_RUNTIME`, указывающей на JSON из `tests.export_renpy_runtime`.
|
||||||
|
`PGAMES_MIST_ORIGINAL` включает backend-проверку оригинального дистрибутива.
|
||||||
|
Полное прохождение сюжета и визуальная проверка каждой сцены не подтверждены.
|
||||||
|
|
||||||
|
В оригинальном `scripts.rpa` есть четыре перехода на отсутствующие метки
|
||||||
|
(`robinspent`, `breakfastdialog`, `firstdungeontalk`, `afterrscene09`) и показ
|
||||||
|
отсутствующего `newbuildingcontrolscreen`. Профиль `renpy/mist-1.0.3` распознаёт
|
||||||
|
сборку дополнительно по SHA-256 сценарного архива и допускает только эти пять
|
||||||
|
дефектов с исходным количеством вхождений. Они остаются предупреждениями;
|
||||||
|
при достижении такого места плеер останавливается с явной ошибкой. Замены
|
||||||
|
сюжетных переходов не подставляются. Изменённые сценарии проверяются заново.
|
||||||
|
|
||||||
|
В отчёте также перечислены 22 ссылки на отсутствующие медиа оригинала,
|
||||||
|
включая старые объявления анимаций, иконку `Roastedfish_Kachel.png` и два
|
||||||
|
side-изображения. Они не подменяются другими картинками; это ограничение
|
||||||
|
самого дистрибутива, которое нужно учитывать при проверке конкретных веток.
|
||||||
|
|
||||||
## Текущая поддержка Naninovel
|
## Текущая поддержка Naninovel
|
||||||
|
|
||||||
Добавлен импорт скомпилированных сценариев из Unity-ресурсов Mono-сборок
|
Добавлен импорт скомпилированных сценариев из Unity-ресурсов Mono-сборок
|
||||||
|
|||||||
@@ -1,6 +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";
|
import { ColorMatrix } from "./colormatrix";
|
||||||
|
import { callableOf } from "./python";
|
||||||
|
|
||||||
export type AtlProps = Record<string, any>;
|
export type AtlProps = Record<string, any>;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ 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; args?: any[]; kw?: Record<string, any> }
|
| { op: "use"; name: string; args?: any[]; kw?: Record<string, any>; pause?: any }
|
||||||
| { op: "image"; img: any; with: any }
|
| { op: "image"; img: any; with: any }
|
||||||
| { op: "contains"; img: any }
|
| { op: "contains"; img: any }
|
||||||
| { op: "function"; f: any }
|
| { op: "function"; f: any }
|
||||||
@@ -38,7 +39,7 @@ export interface AtlHost {
|
|||||||
/** 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. */
|
/** `"other.png" with dissolve`: show another picture in the animated element. */
|
||||||
image?(img: any, transition: any): void;
|
image?(img: any, transition: any, frame?: Map<string, any>): void;
|
||||||
/** `contains:`: add a child picture that runs its own timeline. */
|
/** `contains:`: add a child picture that runs its own timeline. */
|
||||||
contains?(img: any): void;
|
contains?(img: any): void;
|
||||||
}
|
}
|
||||||
@@ -258,12 +259,19 @@ export class AtlRunner {
|
|||||||
if (props) {
|
if (props) {
|
||||||
Object.assign(this.props, props);
|
Object.assign(this.props, props);
|
||||||
this.apply();
|
this.apply();
|
||||||
|
} else if (step.pause !== undefined && this.host.eval) {
|
||||||
|
// not a transform: a number (or a function returning one) the game defines is a wait
|
||||||
|
const wait = Number(this.host.eval(step.pause, this.vars));
|
||||||
|
if (Number.isFinite(wait) && wait > 0) {
|
||||||
|
await this.sleep(wait, token);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
case "image":
|
case "image":
|
||||||
this.host.image?.(step.img, step.with);
|
this.host.image?.(step.img, step.with, this.vars);
|
||||||
return false;
|
return false;
|
||||||
case "contains":
|
case "contains":
|
||||||
this.host.contains?.(step.img);
|
this.host.contains?.(step.img);
|
||||||
@@ -286,8 +294,8 @@ export class AtlRunner {
|
|||||||
|
|
||||||
/** `function f`: Python moves the element. f(trans, st, at) returns the delay until the next call, or None. */
|
/** `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) {
|
private async callFunction(expr: any, token: number) {
|
||||||
const f = this.host.eval ? this.host.eval(expr, this.vars) : null;
|
const f = callableOf(this.host.eval ? this.host.eval(expr, this.vars) : null);
|
||||||
if (typeof f !== "function") return;
|
if (!f) return;
|
||||||
const props = this.props;
|
const props = this.props;
|
||||||
const trans = new Proxy({}, {
|
const trans = new Proxy({}, {
|
||||||
get: (_t, key: string) => props[key] ?? this.current(key),
|
get: (_t, key: string) => props[key] ?? this.current(key),
|
||||||
@@ -337,6 +345,15 @@ export class AtlRunner {
|
|||||||
const target = this.props_(step.props);
|
const target = this.props_(step.props);
|
||||||
const from: AtlProps = {};
|
const from: AtlProps = {};
|
||||||
for (const key of Object.keys(target)) from[key] = this.current(key);
|
for (const key of Object.keys(target)) from[key] = this.current(key);
|
||||||
|
// `pos (x, y) knot (a, b)`: a Bezier curve from here through the knots (controls) to the end value
|
||||||
|
const splines: Record<string, any[]> = {};
|
||||||
|
for (const [key, to] of Object.entries(target)) {
|
||||||
|
if (to && typeof to === "object" && !Array.isArray(to) && Array.isArray((to as any).spline)) {
|
||||||
|
const points = (to as any).spline.map((p: any) => this.val(p));
|
||||||
|
splines[key] = [from[key], ...points];
|
||||||
|
target[key] = points[points.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
const curve = curves[step.warp] ?? curves.linear;
|
const curve = curves[step.warp] ?? curves.linear;
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const duration = seconds * 1000;
|
const duration = seconds * 1000;
|
||||||
@@ -345,7 +362,8 @@ export class AtlRunner {
|
|||||||
this.alive(token);
|
this.alive(token);
|
||||||
const raw = Math.min(1, (performance.now() - start) / duration);
|
const raw = Math.min(1, (performance.now() - start) / duration);
|
||||||
const k = curve(raw);
|
const k = curve(raw);
|
||||||
for (const [key, to] of Object.entries(target)) this.props[key] = mix(from[key], to, k);
|
for (const [key, to] of Object.entries(target))
|
||||||
|
this.props[key] = splines[key] ? bezier(splines[key], k) : mix(from[key], to, k);
|
||||||
this.apply();
|
this.apply();
|
||||||
if (raw >= 1) return;
|
if (raw >= 1) return;
|
||||||
}
|
}
|
||||||
@@ -354,6 +372,13 @@ export class AtlRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** De Casteljau: the point at k of the Bezier curve with these control points. */
|
||||||
|
function bezier(points: any[], k: number): any {
|
||||||
|
let level = points;
|
||||||
|
while (level.length > 1) level = level.slice(1).map((p, i) => mix(level[i], p, k));
|
||||||
|
return level[0];
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
if (a instanceof ColorMatrix && b instanceof ColorMatrix) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export interface Volumes {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Channel {
|
interface Channel {
|
||||||
el: HTMLAudioElement | null;
|
el: HTMLMediaElement | null;
|
||||||
file: string | null;
|
file: string | null;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
queue: string[];
|
queue: string[];
|
||||||
@@ -42,16 +42,27 @@ interface Playback {
|
|||||||
export class AudioSystem {
|
export class AudioSystem {
|
||||||
channels = new Map<string, Channel>();
|
channels = new Map<string, Channel>();
|
||||||
// Includes outgoing tracks until their fade finishes.
|
// Includes outgoing tracks until their fade finishes.
|
||||||
private playing = new Map<HTMLAudioElement, Playback>();
|
private playing = new Map<HTMLMediaElement, Playback>();
|
||||||
|
/** Called when a channel runs out of tracks by itself (not when it is stopped). */
|
||||||
|
onFinished = new Map<string, () => void>();
|
||||||
|
private mixers = new Map<string, number>();
|
||||||
|
|
||||||
constructor(public volumes: Volumes, private url: (file: string) => string | null,
|
constructor(public volumes: Volumes, private url: (file: string) => string | null,
|
||||||
private registered: Record<string, { mixer: string; loop: boolean }> = {}) {}
|
private registered: Record<string, { mixer: string; loop: boolean }> = {}) {}
|
||||||
|
|
||||||
private group(ch: string): keyof Volumes {
|
mixerVolume(name: string): number {
|
||||||
const mixer = this.registered[ch]?.mixer ?? ch;
|
if (name === "sfx") name = "sound";
|
||||||
if (mixer === "music") return "music";
|
if (name === "music" || name === "voice" || name === "sound") return this.volumes[name];
|
||||||
if (mixer === "voice") return "voice";
|
return this.mixers.get(name) ?? 1;
|
||||||
return "sound";
|
}
|
||||||
|
|
||||||
|
setMixer(name: string, value: number) {
|
||||||
|
if (!Number.isFinite(value)) throw new Error("Громкость микшера должна быть числом");
|
||||||
|
value = Math.max(0, Math.min(1, value));
|
||||||
|
if (name === "sfx") name = "sound";
|
||||||
|
if (name === "music" || name === "voice" || name === "sound") this.volumes[name] = value;
|
||||||
|
else this.mixers.set(name, value);
|
||||||
|
for (const [el, playback] of this.playing) this.applyVolume(el, playback);
|
||||||
}
|
}
|
||||||
|
|
||||||
private channel(name: string): Channel {
|
private channel(name: string): Channel {
|
||||||
@@ -92,7 +103,9 @@ export class AudioSystem {
|
|||||||
console.warn("[pgvn] нет аудиофайла", file);
|
console.warn("[pgvn] нет аудиофайла", file);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const el = new Audio(src);
|
// the movie channel shows a picture: the player puts this element on screen (see mediaOf)
|
||||||
|
const el: HTMLMediaElement = ch === "movie" ? document.createElement("video") : new Audio(src);
|
||||||
|
if (ch === "movie") el.src = src;
|
||||||
const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, volume: c.volume, cancelFade: () => {} };
|
const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, volume: c.volume, cancelFade: () => {} };
|
||||||
this.playing.set(el, playback);
|
this.playing.set(el, playback);
|
||||||
this.applyVolume(el, playback);
|
this.applyVolume(el, playback);
|
||||||
@@ -108,6 +121,7 @@ export class AudioSystem {
|
|||||||
} else {
|
} else {
|
||||||
c.el = null;
|
c.el = null;
|
||||||
c.file = null;
|
c.file = null;
|
||||||
|
this.onFinished.get(ch)?.();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
c.el = el;
|
c.el = el;
|
||||||
@@ -116,11 +130,13 @@ export class AudioSystem {
|
|||||||
if (fadein > 0) this.fadePlayback(el, playback, 1, fadein);
|
if (fadein > 0) this.fadePlayback(el, playback, 1, fadein);
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyVolume(el: HTMLAudioElement, playback: Playback) {
|
private applyVolume(el: HTMLMediaElement, playback: Playback) {
|
||||||
el.volume = Math.max(0, Math.min(1, this.volumes[this.group(playback.channel)] * playback.gain * playback.volume));
|
const mixer = this.registered[playback.channel]?.mixer ??
|
||||||
|
(playback.channel === "music" || playback.channel === "voice" ? playback.channel : "sfx");
|
||||||
|
el.volume = Math.max(0, Math.min(1, this.mixerVolume(mixer) * playback.gain * playback.volume));
|
||||||
}
|
}
|
||||||
|
|
||||||
private fadePlayback(el: HTMLAudioElement, playback: Playback, to: number, seconds: number,
|
private fadePlayback(el: HTMLMediaElement, playback: Playback, to: number, seconds: number,
|
||||||
done?: () => void) {
|
done?: () => void) {
|
||||||
playback.cancelFade();
|
playback.cancelFade();
|
||||||
playback.cancelFade = fade(playback.gain, to, seconds, (gain) => {
|
playback.cancelFade = fade(playback.gain, to, seconds, (gain) => {
|
||||||
@@ -157,6 +173,38 @@ export class AudioSystem {
|
|||||||
for (const [el, playback] of this.playing) this.applyVolume(el, playback);
|
for (const [el, playback] of this.playing) this.applyVolume(el, playback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The element playing on a channel (a <video> on the movie channel). */
|
||||||
|
mediaOf(ch: string): HTMLMediaElement | null {
|
||||||
|
return this.channels.get(ch)?.el ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seconds played of the current track, or null when nothing plays. */
|
||||||
|
position(ch: string): number | null {
|
||||||
|
const el = this.channels.get(ch)?.el;
|
||||||
|
return el ? el.currentTime : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
duration(ch: string): number | null {
|
||||||
|
const el = this.channels.get(ch)?.el;
|
||||||
|
return el && Number.isFinite(el.duration) ? el.duration : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
paused(ch: string): boolean {
|
||||||
|
return this.channels.get(ch)?.el?.paused ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPaused(ch: string, value: boolean) {
|
||||||
|
const el = this.channels.get(ch)?.el;
|
||||||
|
if (!el) return;
|
||||||
|
if (value) el.pause();
|
||||||
|
else el.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(ch: string, seconds: number) {
|
||||||
|
const el = this.channels.get(ch)?.el;
|
||||||
|
if (el && Number.isFinite(seconds)) el.currentTime = seconds;
|
||||||
|
}
|
||||||
|
|
||||||
current(ch: string): { file: string; loop: boolean } | null {
|
current(ch: string): { file: string; loop: boolean } | null {
|
||||||
const c = this.channels.get(ch);
|
const c = this.channels.get(ch);
|
||||||
return c?.file ? { file: c.file, loop: c.loop } : null;
|
return c?.file ? { file: c.file, loop: c.loop } : null;
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/** Fullscreen movie interaction. Completion preserves the distinction between skip and end. */
|
||||||
|
export function cutscene(parent: HTMLElement, src: string, options: { delay: number | null; loops: number; volume: number }) {
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "pg-cutscene";
|
||||||
|
overlay.tabIndex = 0;
|
||||||
|
Object.assign(overlay.style, { position: "absolute", inset: "0", zIndex: "10000", background: "black", display: "grid" });
|
||||||
|
const video = document.createElement("video");
|
||||||
|
Object.assign(video.style, { width: "100%", height: "100%", objectFit: "contain", gridArea: "1 / 1" });
|
||||||
|
video.src = src;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.volume = options.volume;
|
||||||
|
video.loop = options.loops === -1;
|
||||||
|
const play = document.createElement("button");
|
||||||
|
play.textContent = "Воспроизвести";
|
||||||
|
Object.assign(play.style, { gridArea: "1 / 1", placeSelf: "center", zIndex: "1" });
|
||||||
|
play.hidden = true;
|
||||||
|
overlay.append(video, play);
|
||||||
|
parent.append(overlay);
|
||||||
|
let finish: (value: boolean) => void;
|
||||||
|
let fail: (error: Error) => void;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let ended = 0;
|
||||||
|
let settled = false;
|
||||||
|
let clockStarted = false;
|
||||||
|
const finished = new Promise<boolean>((resolve, reject) => { finish = resolve; fail = reject; });
|
||||||
|
const done = (interrupted: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
finish(interrupted);
|
||||||
|
};
|
||||||
|
const start = () => {
|
||||||
|
play.hidden = true;
|
||||||
|
// A blocked autoplay needs a real user gesture; it must not silently skip the movie.
|
||||||
|
Promise.resolve(video.play()).catch(() => { play.hidden = false; });
|
||||||
|
};
|
||||||
|
video.onplaying = () => {
|
||||||
|
if (clockStarted) return;
|
||||||
|
clockStarted = true;
|
||||||
|
if (options.delay !== null && options.delay >= 0) timer = setTimeout(() => done(false), options.delay * 1000);
|
||||||
|
};
|
||||||
|
video.onended = () => {
|
||||||
|
if (options.delay !== null) return;
|
||||||
|
if (options.loops === -1) return;
|
||||||
|
if (ended++ < options.loops) { video.currentTime = 0; start(); }
|
||||||
|
else done(false);
|
||||||
|
};
|
||||||
|
video.onerror = () => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
fail(new Error("Не удалось воспроизвести ролик"));
|
||||||
|
};
|
||||||
|
play.onclick = (event) => { event.stopPropagation(); start(); };
|
||||||
|
overlay.onclick = (event) => { event.stopPropagation(); done(true); };
|
||||||
|
overlay.onkeydown = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (["Enter", " ", "Escape"].includes(event.key)) { event.preventDefault(); done(true); }
|
||||||
|
};
|
||||||
|
overlay.focus();
|
||||||
|
start();
|
||||||
|
return { finished, destroy: () => {
|
||||||
|
done(false);
|
||||||
|
clearTimeout(timer);
|
||||||
|
video.onplaying = video.onended = video.onerror = null;
|
||||||
|
video.pause();
|
||||||
|
video.removeAttribute("src");
|
||||||
|
overlay.remove();
|
||||||
|
} };
|
||||||
|
}
|
||||||
+187
-13
@@ -1,14 +1,16 @@
|
|||||||
// PGVN interpreter + in-game UI.
|
// PGVN interpreter + in-game UI.
|
||||||
|
|
||||||
import { AudioSystem } from "./audio";
|
import { AudioSystem } from "./audio";
|
||||||
|
import { cutscene } from "./cutscene";
|
||||||
import { VolumeStore } from "./volume-store";
|
import { VolumeStore } from "./volume-store";
|
||||||
import { LiveImages } from "../imager/live";
|
import { LiveImages } from "../imager/live";
|
||||||
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||||||
import { Renderer, type ResolvedImage } from "./renderer";
|
import { Renderer, type ResolvedImage } from "./renderer";
|
||||||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||||
import { naniText } from "./naninovel";
|
import { naniText } from "./naninovel";
|
||||||
import { ScreenManager, type ScreenHost } from "./screens";
|
import { Displayable, ScreenManager, type ScreenHost } from "./screens";
|
||||||
|
import { musicRoomClass } from "./musicroom";
|
||||||
import { pythonModules } from "./pymodules";
|
import { pythonModules } from "./pymodules";
|
||||||
import { parseExpression, parseStatements } from "./pyparse";
|
import { parseExpression, parseStatements } from "./pyparse";
|
||||||
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";
|
||||||
@@ -30,6 +32,8 @@ export class Engine {
|
|||||||
private labels = new Map<string, LabelRef>();
|
private labels = new Map<string, LabelRef>();
|
||||||
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
|
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
|
||||||
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
|
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
|
||||||
|
private originalImages: Record<string, ImageDef>;
|
||||||
|
private registeredImages: Record<string, ImageDef> = {};
|
||||||
private store: Record<string, any> = {};
|
private store: Record<string, any> = {};
|
||||||
private builtins: Record<string, any> = {};
|
private builtins: Record<string, any> = {};
|
||||||
private persistent: Record<string, any>;
|
private persistent: Record<string, any>;
|
||||||
@@ -89,6 +93,7 @@ export class Engine {
|
|||||||
audioKey: string | null = null,
|
audioKey: string | null = null,
|
||||||
) {
|
) {
|
||||||
this.volumes = new VolumeStore(audioKey);
|
this.volumes = new VolumeStore(audioKey);
|
||||||
|
this.originalImages = { ...game.images };
|
||||||
for (const mod of modules) {
|
for (const mod of modules) {
|
||||||
let global = "";
|
let global = "";
|
||||||
mod.labels.forEach((label, index) => {
|
mod.labels.forEach((label, index) => {
|
||||||
@@ -130,6 +135,7 @@ export class Engine {
|
|||||||
this.py.blockingNames = new Set([
|
this.py.blockingNames = new Set([
|
||||||
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
|
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
|
||||||
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
|
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
|
||||||
|
"ui.interact", "renpy.movie_cutscene",
|
||||||
]);
|
]);
|
||||||
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() };
|
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() };
|
||||||
|
|
||||||
@@ -156,6 +162,7 @@ export class Engine {
|
|||||||
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
|
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
|
||||||
});
|
});
|
||||||
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
|
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
|
||||||
|
this.builtins.MusicRoom = musicRoomClass(this.audio);
|
||||||
this.buildUI();
|
this.buildUI();
|
||||||
if (manifest.versionId !== undefined) this.liveImages = new LiveImages(manifest.versionId, (message) => this.notify(`Улучшение графики: ${message}`));
|
if (manifest.versionId !== undefined) this.liveImages = new LiveImages(manifest.versionId, (message) => this.notify(`Улучшение графики: ${message}`));
|
||||||
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
|
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
|
||||||
@@ -223,6 +230,45 @@ export class Engine {
|
|||||||
return { def: this.game.images[best.key], name: best.key.split(" ") };
|
return { def: this.game.images[best.key], name: best.key.split(" ") };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private indexImage(name: string) {
|
||||||
|
const [tag, ...attrs] = name.split(" ");
|
||||||
|
const entries = this.imagesByTag.get(tag) ?? [];
|
||||||
|
if (!entries.some((entry) => entry.key === name)) entries.push({ attrs, key: name });
|
||||||
|
this.imagesByTag.set(tag, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerImage(name: string, definition: ImageDef) {
|
||||||
|
Object.defineProperty(this.game.images, name, { value: definition, enumerable: true, configurable: true, writable: true });
|
||||||
|
Object.defineProperty(this.registeredImages, name, { value: definition, enumerable: true, configurable: true, writable: true });
|
||||||
|
this.indexImage(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runtime renpy.image declarations become the same serializable image IR as static ones. */
|
||||||
|
private imageDefinition(value: any, depth = 0): ImageDef {
|
||||||
|
if (depth > 8) throw new Error("Слишком глубокое определение изображения");
|
||||||
|
if (typeof value === "string") return /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value }
|
||||||
|
: Object.hasOwn(this.game.images, value) ? { ref: value } : { file: value };
|
||||||
|
if (!(value instanceof Displayable)) throw new Error("Неподдерживаемое значение renpy.image");
|
||||||
|
const [a, b, c] = value.args;
|
||||||
|
switch (value.kind) {
|
||||||
|
case "image": return this.imageDefinition(a, depth + 1);
|
||||||
|
case "scale": {
|
||||||
|
const child = this.imageDefinition(a, depth + 1);
|
||||||
|
return { ...child, props: { ...child.props, size: [b, c] } };
|
||||||
|
}
|
||||||
|
case "transform": {
|
||||||
|
const child = this.imageDefinition(a, depth + 1);
|
||||||
|
return { ...child, props: { ...child.props, ...value.kw } };
|
||||||
|
}
|
||||||
|
case "solid": return { color: pyStr(a) };
|
||||||
|
case "movie": return { movie: pyStr(value.kw.play ?? a), movieMask: value.kw.mask ? pyStr(value.kw.mask) : undefined,
|
||||||
|
movieLoop: value.kw.loop !== false };
|
||||||
|
case "text": return { text: pyStr(a) };
|
||||||
|
case "null": return { composite: [] };
|
||||||
|
}
|
||||||
|
throw new Error(`renpy.image: не поддерживается ${value.kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Attributes of a layeredimage: new ones replace current ones of the same group. */
|
/** Attributes of a layeredimage: new ones replace current ones of the same group. */
|
||||||
private resolveLayered(tag: string, def: ImageDef, attrs: string[]): ResolvedImage {
|
private resolveLayered(tag: string, def: ImageDef, attrs: string[]): ResolvedImage {
|
||||||
const layers = def.layered!;
|
const layers = def.layered!;
|
||||||
@@ -318,7 +364,13 @@ export class Engine {
|
|||||||
},
|
},
|
||||||
stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0),
|
stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0),
|
||||||
set_volume: () => {},
|
set_volume: () => {},
|
||||||
set_pause: () => {},
|
set_pause: (...a: any[]) => {
|
||||||
|
this.audio.setPaused(kwOf(a).channel ?? ch, truthy(kwless(a)[0] ?? true));
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
get_pause: (...a: any[]) => this.audio.paused(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||||||
|
get_pos: (...a: any[]) => this.audio.position(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||||||
|
get_duration: (...a: any[]) => this.audio.duration(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||||||
set_pan: () => {},
|
set_pan: () => {},
|
||||||
get_playing: () => this.audio.current(ch)?.file ?? null,
|
get_playing: () => this.audio.current(ch)?.file ?? null,
|
||||||
is_playing: () => this.audio.current(ch) !== null,
|
is_playing: () => this.audio.current(ch) !== null,
|
||||||
@@ -342,6 +394,9 @@ export class Engine {
|
|||||||
display_menu: (items: any, ...a: any[]) => new BlockingCall("menu", [items], kwOf(a)),
|
display_menu: (items: any, ...a: any[]) => new BlockingCall("menu", [items], kwOf(a)),
|
||||||
with_statement: (transition: any) => new BlockingCall("with", [transition]),
|
with_statement: (transition: any) => new BlockingCall("with", [transition]),
|
||||||
call_screen: (name: any, ...a: any[]) => new BlockingCall("screen", [name, ...kwless(a)], kwOf(a)),
|
call_screen: (name: any, ...a: any[]) => new BlockingCall("screen", [name, ...kwless(a)], kwOf(a)),
|
||||||
|
movie_cutscene: (...a: any[]) => new BlockingCall("movie", kwless(a), kwOf(a)),
|
||||||
|
free_memory: () => null, // browser owns the decoded-media cache
|
||||||
|
image: (name: any, value: any) => { this.registerImage(Array.isArray(name) ? name.join(" ") : String(name), this.imageDefinition(value)); return null; },
|
||||||
show: (name: any, ...a: any[]) => {
|
show: (name: any, ...a: any[]) => {
|
||||||
const kw = kwOf(a);
|
const kw = kwOf(a);
|
||||||
const parts = String(name).split(" ");
|
const parts = String(name).split(" ");
|
||||||
@@ -410,6 +465,34 @@ export class Engine {
|
|||||||
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)),
|
||||||
block_rollback: () => null,
|
block_rollback: () => null,
|
||||||
|
choice_for_skipping: () => null,
|
||||||
|
// text effects drawn by GPU shaders: the words stay, only the glow is missing
|
||||||
|
register_textshader: () => null,
|
||||||
|
save_persistent: () => null, // persistent data is written on every change
|
||||||
|
mark_label_seen: (label: any) => {
|
||||||
|
const seen: string[] = this.persistent._seen ?? [];
|
||||||
|
if (!seen.includes(String(label))) this.persistent._seen = [...seen, String(label)];
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
get_game_runtime: () => (performance.now() - this.startedAt) / 1000,
|
||||||
|
translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null),
|
||||||
|
substitute: (text: any) => this.interp(pyStr(text)),
|
||||||
|
return_statement: () => {
|
||||||
|
throw new JumpSignal("return", "");
|
||||||
|
},
|
||||||
|
checkpoint: () => null,
|
||||||
|
start_predict: () => null,
|
||||||
|
start_predict_screen: () => null,
|
||||||
|
stop_predict_screen: () => null,
|
||||||
|
// named slots; the name is limited to what the server accepts
|
||||||
|
save: (name: any) => {
|
||||||
|
void this.save(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32), true);
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
load: (name: any) => {
|
||||||
|
void this.load(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32));
|
||||||
|
return null;
|
||||||
|
},
|
||||||
transition: () => null,
|
transition: () => null,
|
||||||
};
|
};
|
||||||
const renpy = new Proxy(renpyImpl, {
|
const renpy = new Proxy(renpyImpl, {
|
||||||
@@ -423,7 +506,7 @@ export class Engine {
|
|||||||
return {
|
return {
|
||||||
renpy,
|
renpy,
|
||||||
persistent: this.persistent,
|
persistent: this.persistent,
|
||||||
config: {},
|
config: { gestures: {}, character_id_prefixes: [], screen_width: this.game.resolution[0], screen_height: this.game.resolution[1] },
|
||||||
// games branch on the Ren'Py language name ("russian"); None means the source language
|
// games branch on the Ren'Py language name ("russian"); None means the source language
|
||||||
preferences: this.languagePrefs(),
|
preferences: this.languagePrefs(),
|
||||||
_preferences: this.languagePrefs(),
|
_preferences: this.languagePrefs(),
|
||||||
@@ -456,6 +539,16 @@ export class Engine {
|
|||||||
},
|
},
|
||||||
Borders: (...a: number[]) => a,
|
Borders: (...a: number[]) => a,
|
||||||
...pythonModules(),
|
...pythonModules(),
|
||||||
|
...nativeClasses(),
|
||||||
|
build: { ...this.game.build },
|
||||||
|
ui: { adjustment: () => ({ value: 0, range: 1, page: 0 }),
|
||||||
|
interact: () => new BlockingCall("interact") },
|
||||||
|
divmod: (a: number, b: number) => [Math.floor(a / b), ((a % b) + b) % b],
|
||||||
|
pow: (a: number, b: number, m?: number) => (m ? Number((BigInt(a) ** BigInt(b)) % BigInt(m)) : a ** b),
|
||||||
|
_p: (text: any) => this.tr.str(pyStr(text)).replace(/[ \t]*\n[ \t]*/g, " ").trim(),
|
||||||
|
achievement: this.achievementApi(),
|
||||||
|
// objects the game can reach, as the garbage collector would list them
|
||||||
|
gc: { get_objects: () => liveObjects(this.store), collect: () => 0 },
|
||||||
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
|
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
|
||||||
_window_show: () => { this.windowShown = true; 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",
|
||||||
@@ -597,10 +690,45 @@ export class Engine {
|
|||||||
this.screens.hideCall(name);
|
this.screens.hideCall(name);
|
||||||
return value ?? null;
|
return value ?? null;
|
||||||
}
|
}
|
||||||
|
case "interact": {
|
||||||
|
this.interactionStart();
|
||||||
|
this.screens.refresh();
|
||||||
|
let cancel = () => {};
|
||||||
|
try { return await this.wait<any>((done) => { cancel = this.screens.interact(done); }); }
|
||||||
|
finally { cancel(); }
|
||||||
|
}
|
||||||
|
case "movie":
|
||||||
|
return this.movieCutscene(request.args, request.kw);
|
||||||
}
|
}
|
||||||
throw new Error(`Неизвестный запрос ${request.kind}`);
|
throw new Error(`Неизвестный запрос ${request.kind}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async movieCutscene(args: any[], kw: Record<string, any>): Promise<boolean> {
|
||||||
|
const filename = this.interp(pyStr(args[0] ?? kw.filename));
|
||||||
|
const src = this.fileUrl(filename);
|
||||||
|
if (!src) throw new Error(`Не найден ролик: ${filename}`);
|
||||||
|
const delay = args[1] ?? kw.delay ?? null;
|
||||||
|
const loops = Number(args[2] ?? kw.loops ?? 0);
|
||||||
|
if ((delay !== null && (!Number.isFinite(delay) || delay < -1)) || !Number.isInteger(loops) || loops < -1)
|
||||||
|
throw new Error("Некорректная длительность или число повторений ролика");
|
||||||
|
const stopMusic = args[3] ?? kw.stop_music ?? true;
|
||||||
|
const paused = this.audio.paused("music");
|
||||||
|
if (stopMusic) this.audio.setPaused("music", true);
|
||||||
|
const restoreScreens = this.screens.suspend();
|
||||||
|
const movie = cutscene(this.container, src, { delay, loops, volume: this.audio.mixerVolume("sfx") });
|
||||||
|
try {
|
||||||
|
const result = await this.wait<{ value?: boolean; error?: any }>((done) => {
|
||||||
|
movie.finished.then((value) => done({ value }), (error) => done({ error }));
|
||||||
|
});
|
||||||
|
if (result.error) throw result.error;
|
||||||
|
return result.value!;
|
||||||
|
} finally {
|
||||||
|
movie.destroy();
|
||||||
|
restoreScreens();
|
||||||
|
if (stopMusic) this.audio.setPaused("music", paused);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** renpy.input(): a text field over the scene. */
|
/** renpy.input(): a text field over the scene. */
|
||||||
private askText(prompt: string, initial: string, kw: Record<string, any>): Promise<string> {
|
private askText(prompt: string, initial: string, kw: Record<string, any>): Promise<string> {
|
||||||
this.interactionStart();
|
this.interactionStart();
|
||||||
@@ -658,9 +786,46 @@ export class Engine {
|
|||||||
}).finally(() => this.menuEl.classList.add("pg-hidden"));
|
}).finally(() => this.menuEl.classList.add("pg-hidden"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startedAt = performance.now();
|
||||||
|
|
||||||
|
/** Ren'Py's achievement API on top of persistent data (there is no Steam in the browser). */
|
||||||
|
private achievementApi() {
|
||||||
|
const got = (): string[] => this.persistent._achievements ?? [];
|
||||||
|
return {
|
||||||
|
steam: null,
|
||||||
|
register: () => null,
|
||||||
|
sync: () => null,
|
||||||
|
has: (name: any) => got().includes(String(name)),
|
||||||
|
grant: (name: any) => {
|
||||||
|
if (!got().includes(String(name))) {
|
||||||
|
this.persistent._achievements = [...got(), String(name)];
|
||||||
|
this.notify(`Достижение: ${pyStr(name)}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
progress: () => null,
|
||||||
|
clear: (name: any) => {
|
||||||
|
this.persistent._achievements = got().filter((n) => n !== String(name));
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
clear_all: () => { this.persistent._achievements = []; return null; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private screenHost(): ScreenHost {
|
private screenHost(): ScreenHost {
|
||||||
const engine = this;
|
const engine = this;
|
||||||
return {
|
return {
|
||||||
|
error: (message) => this.fail(new Error(message)),
|
||||||
|
get audio() { return engine.audio; },
|
||||||
|
setMixer: (name, value) => {
|
||||||
|
this.audio.setMixer(name, value);
|
||||||
|
const key = name === "sfx" ? "sound" : name;
|
||||||
|
if (key in this.settings) {
|
||||||
|
(this.settings as any)[key] = value;
|
||||||
|
this.audio.setVolumes(this.settings);
|
||||||
|
this.persistSettings("audio");
|
||||||
|
}
|
||||||
|
},
|
||||||
py: this.py,
|
py: this.py,
|
||||||
fileUrl: (path) => this.fileUrl(path),
|
fileUrl: (path) => this.fileUrl(path),
|
||||||
image: (value) => this.screenImage(value),
|
image: (value) => this.screenImage(value),
|
||||||
@@ -725,6 +890,10 @@ export class Engine {
|
|||||||
|
|
||||||
private resetStore() {
|
private resetStore() {
|
||||||
this.screens.clear();
|
this.screens.clear();
|
||||||
|
this.game.images = { ...this.originalImages };
|
||||||
|
this.registeredImages = {};
|
||||||
|
this.imagesByTag.clear();
|
||||||
|
for (const name of Object.keys(this.game.images)) this.indexImage(name);
|
||||||
this.store = {};
|
this.store = {};
|
||||||
try {
|
try {
|
||||||
this.py.exec(this.game.init);
|
this.py.exec(this.game.init);
|
||||||
@@ -798,11 +967,7 @@ export class Engine {
|
|||||||
if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name;
|
if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name;
|
||||||
const anchor = this.labels.has(name) ? null : this.anchors.get(name);
|
const anchor = this.labels.has(name) ? null : this.anchors.get(name);
|
||||||
if (!this.labels.has(name) && !anchor) {
|
if (!this.labels.has(name) && !anchor) {
|
||||||
this.notify(`Метка «${target}» не найдена`);
|
throw new Error(`Метка «${target}» не найдена`);
|
||||||
console.warn("[pgvn] нет метки", target);
|
|
||||||
if (frame) this.stack.pop();
|
|
||||||
this.advance();
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
const params = anchor ? anchor.params : this.labels.get(name)!.label.params;
|
const params = anchor ? anchor.params : this.labels.get(name)!.label.params;
|
||||||
if (params?.length) this.bindParams(params, args, frame);
|
if (params?.length) this.bindParams(params, args, frame);
|
||||||
@@ -905,6 +1070,7 @@ export class Engine {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof JumpSignal) {
|
if (err instanceof JumpSignal) {
|
||||||
if (err.kind === "jump") this.jump(err.target);
|
if (err.kind === "jump") this.jump(err.target);
|
||||||
|
else if (err.kind === "return") this.doReturn();
|
||||||
else this.call(err.target);
|
else this.call(err.target);
|
||||||
} else throw err;
|
} else throw err;
|
||||||
}
|
}
|
||||||
@@ -915,8 +1081,7 @@ export class Engine {
|
|||||||
if (err.message === "__quit__") this.onExit();
|
if (err.message === "__quit__") this.onExit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error(err);
|
this.fail(err);
|
||||||
this.notify(`Ошибка плеера: ${(err as Error).message}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === this.gen) this.running = false;
|
if (gen === this.gen) this.running = false;
|
||||||
}
|
}
|
||||||
@@ -1073,8 +1238,7 @@ export class Engine {
|
|||||||
else this.py.exec(node.code);
|
else this.py.exec(node.code);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof JumpSignal) throw err;
|
if (err instanceof JumpSignal) throw err;
|
||||||
if (this.game.engine === "naninovel") throw err;
|
throw err;
|
||||||
console.warn("[pgvn] ошибка python", err, node.code);
|
|
||||||
}
|
}
|
||||||
this.advance();
|
this.advance();
|
||||||
return;
|
return;
|
||||||
@@ -1383,6 +1547,14 @@ export class Engine {
|
|||||||
window.setTimeout(() => this.notifyEl.classList.add("pg-hidden"), 3000);
|
window.setTimeout(() => this.notifyEl.classList.add("pg-hidden"), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fail(error: any) {
|
||||||
|
this.gen++;
|
||||||
|
this.wakeAll();
|
||||||
|
this.running = false;
|
||||||
|
this.screens.suspend();
|
||||||
|
this.notify(`Ошибка плеера: ${error?.message ?? String(error)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// --- session control --------------------------------------------------------------
|
// --- session control --------------------------------------------------------------
|
||||||
|
|
||||||
private restart(setup: () => void) {
|
private restart(setup: () => void) {
|
||||||
@@ -1487,6 +1659,7 @@ export class Engine {
|
|||||||
store: encodeValue(this.store),
|
store: encodeValue(this.store),
|
||||||
screens: encodeValue(this.screens.snapshot()),
|
screens: encodeValue(this.screens.snapshot()),
|
||||||
scene: this.renderer.snapshot(),
|
scene: this.renderer.snapshot(),
|
||||||
|
registeredImages: { ...this.registeredImages },
|
||||||
music: this.audio.current("music"),
|
music: this.audio.current("music"),
|
||||||
window: this.windowShown,
|
window: this.windowShown,
|
||||||
camera: this.renderer.cameraAt,
|
camera: this.renderer.cameraAt,
|
||||||
@@ -1535,6 +1708,7 @@ export class Engine {
|
|||||||
/** Put the game into the state described by a save (also used to come back from a replay). */
|
/** Put the game into the state described by a save (also used to come back from a replay). */
|
||||||
private applySave(data: SaveData) {
|
private applySave(data: SaveData) {
|
||||||
this.resetStore();
|
this.resetStore();
|
||||||
|
for (const [name, definition] of Object.entries(data.registeredImages ?? {})) this.registerImage(name, definition);
|
||||||
Object.assign(this.store, decodeValue(data.store));
|
Object.assign(this.store, decodeValue(data.store));
|
||||||
this.screens.restore(decodeValue(data.screens ?? []));
|
this.screens.restore(decodeValue(data.screens ?? []));
|
||||||
this.renderer.scene();
|
this.renderer.scene();
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { action } from "./musicroom";
|
||||||
|
import { OBJECT, PyClass, PyDict, nativeMethod, registerNativeClass, truthy, type PyInstance } from "./python";
|
||||||
|
|
||||||
|
type Condition = { kind: "seen"; images: string[] } | { kind: "expression"; source: string };
|
||||||
|
interface Picture { displayables: any[]; conditions: Condition[] }
|
||||||
|
interface Button { pictures: Picture[]; conditions: Condition[] }
|
||||||
|
|
||||||
|
export function galleryClass(host: {
|
||||||
|
seen(name: string): boolean;
|
||||||
|
evaluate(source: string): any;
|
||||||
|
show(pictures: any[][], transition: any): void;
|
||||||
|
button(image: any, enabled: boolean, action: any, properties: Record<string, any>): any;
|
||||||
|
}): PyClass {
|
||||||
|
const gallery = new PyClass("Gallery", [OBJECT]);
|
||||||
|
const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) =>
|
||||||
|
gallery.attrs.set(name, nativeMethod(fn));
|
||||||
|
const conditions = (items: Condition[]) => items.every((c) => c.kind === "seen"
|
||||||
|
? c.images.every((name) => host.seen(name)) : truthy(host.evaluate(c.source)));
|
||||||
|
const get = (self: PyInstance, name: string): Button => {
|
||||||
|
const buttons = self.attrs.buttons as PyDict;
|
||||||
|
if (!buttons.has(name)) throw new Error(`Кнопка галереи «${name}» не определена`);
|
||||||
|
return buttons.get(name);
|
||||||
|
};
|
||||||
|
const pictures = (button: Button) => conditions(button.conditions)
|
||||||
|
? button.pictures.filter((picture) => conditions(picture.conditions)).map((picture) => picture.displayables) : [];
|
||||||
|
const current = (self: PyInstance) => get(self, self.attrs.current_name);
|
||||||
|
const unlockable = (self: PyInstance): Button | Picture => {
|
||||||
|
const button = current(self);
|
||||||
|
return self.attrs.unlockable_index >= 0 ? button.pictures[self.attrs.unlockable_index] : button;
|
||||||
|
};
|
||||||
|
const makeAction = (self: PyInstance, name: string) => {
|
||||||
|
const button = get(self, name);
|
||||||
|
return pictures(button).length ? action(() => host.show(pictures(button), self.attrs.transition),
|
||||||
|
() => false, () => pictures(button).length > 0) : null;
|
||||||
|
};
|
||||||
|
method("__init__", (self) => {
|
||||||
|
self.attrs.buttons = new PyDict();
|
||||||
|
self.attrs.transition = null;
|
||||||
|
self.attrs.locked_button = null;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
method("button", (self, name: string) => {
|
||||||
|
const button: Button = { pictures: [], conditions: [] };
|
||||||
|
(self.attrs.buttons as PyDict).set(name, button);
|
||||||
|
self.attrs.current_name = name;
|
||||||
|
self.attrs.unlockable_index = -1;
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
const image = (self: PyInstance, ...displayables: any[]) => {
|
||||||
|
const button = current(self);
|
||||||
|
const picture: Picture = { displayables, conditions: [] };
|
||||||
|
button.pictures.push(picture);
|
||||||
|
self.attrs.unlockable_index = button.pictures.length - 1;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
method("image", image);
|
||||||
|
method("display", image);
|
||||||
|
const unlock = (self: PyInstance, ...images: string[]) => {
|
||||||
|
unlockable(self).conditions.push({ kind: "seen", images });
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
method("unlock", unlock);
|
||||||
|
method("unlock_image", (self, ...images: string[]) => { image(self, ...images); return unlock(self, ...images); });
|
||||||
|
method("condition", (self, source: string) => {
|
||||||
|
if (typeof source !== "string") throw new Error("Некорректное условие Gallery.condition");
|
||||||
|
unlockable(self).conditions.push({ kind: "expression", source });
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
method("Action", makeAction);
|
||||||
|
method("make_button", (self, ...given: any[]) => {
|
||||||
|
const kw = given.length && given[given.length - 1]?.__kw ? given.pop().__kw : {};
|
||||||
|
const name = given[0] ?? kw.name;
|
||||||
|
const unlocked = given[1] ?? kw.unlocked;
|
||||||
|
const locked = given[2] ?? kw.locked ?? self.attrs.locked_button;
|
||||||
|
const a = makeAction(self, name);
|
||||||
|
const props = { ...kw };
|
||||||
|
for (const key of ["name", "unlocked", "locked"]) delete props[key];
|
||||||
|
return host.button(a ? unlocked : locked, a !== null, a, props);
|
||||||
|
});
|
||||||
|
return registerNativeClass(gallery);
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ export class Translator {
|
|||||||
return (tid && this.lines.get(tid)) || source;
|
return (tid && this.lines.get(tid)) || source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `renpy.translate_string(text, language=...)`: only the loaded language can be applied. */
|
||||||
|
translateIn(source: string, renpyLanguage: string | null): string {
|
||||||
|
return renpyLanguage && renpyLanguage === this.renpyName ? this.str(source) : source;
|
||||||
|
}
|
||||||
|
|
||||||
/** Names, menu choices, `_()` strings: looked up by their source text. */
|
/** Names, menu choices, `_()` strings: looked up by their source text. */
|
||||||
str(source: string): string {
|
str(source: string): string {
|
||||||
return this.strings.get(source) ?? source;
|
return this.strings.get(source) ?? source;
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/** Ren'Py uses the mask movie's red channel as opacity, not its alpha channel. */
|
||||||
|
export function applyMovieMask(picture: Uint8ClampedArray, mask: Uint8ClampedArray) {
|
||||||
|
if (picture.length !== mask.length) throw new Error("Размеры кадров маски не совпадают");
|
||||||
|
for (let i = 3; i < picture.length; i += 4) picture[i] = Math.round(picture[i] * mask[i - 3] / 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function movieDisplayable(source: string, mask: string | null, loop = true): HTMLElement {
|
||||||
|
const video = document.createElement("video");
|
||||||
|
video.src = source;
|
||||||
|
video.autoplay = true;
|
||||||
|
video.loop = loop;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
if (!mask) return video;
|
||||||
|
const alpha = document.createElement("video");
|
||||||
|
alpha.src = mask;
|
||||||
|
alpha.loop = loop;
|
||||||
|
alpha.muted = true;
|
||||||
|
alpha.playsInline = true;
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.className = "pg-masked-movie";
|
||||||
|
const maskCanvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
const maskCtx = maskCanvas.getContext("2d", { willReadFrequently: true });
|
||||||
|
if (!ctx || !maskCtx) throw new Error("Не удалось создать поверхность для видео с маской");
|
||||||
|
let last = -1;
|
||||||
|
let failed = false;
|
||||||
|
const draw = () => {
|
||||||
|
if (failed) return;
|
||||||
|
if (!canvas.isConnected) { video.pause(); alpha.pause(); return; }
|
||||||
|
if (video.readyState >= 2 && alpha.readyState >= 2 && video.videoWidth && video.currentTime !== last) {
|
||||||
|
if (Math.abs(alpha.currentTime - video.currentTime) > 0.06) alpha.currentTime = video.currentTime;
|
||||||
|
const w = video.videoWidth, h = video.videoHeight;
|
||||||
|
if (canvas.width !== w || canvas.height !== h) {
|
||||||
|
canvas.width = maskCanvas.width = w;
|
||||||
|
canvas.height = maskCanvas.height = h;
|
||||||
|
}
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.drawImage(video, 0, 0, w, h);
|
||||||
|
maskCtx.drawImage(alpha, 0, 0, w, h);
|
||||||
|
const pixels = ctx.getImageData(0, 0, w, h);
|
||||||
|
applyMovieMask(pixels.data, maskCtx.getImageData(0, 0, w, h).data);
|
||||||
|
ctx.putImageData(pixels, 0, 0);
|
||||||
|
last = video.currentTime;
|
||||||
|
}
|
||||||
|
if (video.ended && !loop) { video.pause(); alpha.pause(); return; }
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
};
|
||||||
|
video.onerror = alpha.onerror = () => { failed = true; canvas.dataset.error = "Не удалось загрузить видео или его маску"; video.pause(); alpha.pause(); };
|
||||||
|
void video.play().catch(() => {});
|
||||||
|
void alpha.play().catch(() => {});
|
||||||
|
requestAnimationFrame(draw);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Ren'Py's MusicRoom: a playlist with play/stop/next/shuffle actions, as a class games can subclass.
|
||||||
|
|
||||||
|
import type { AudioSystem } from "./audio";
|
||||||
|
import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, registerNativeClass, truthy } from "./python";
|
||||||
|
|
||||||
|
/** An action the player runs when a button is used; `selected`/`sensitive` drive the button's look. */
|
||||||
|
export interface NativeAction {
|
||||||
|
__action__: true;
|
||||||
|
__call__: () => null;
|
||||||
|
get_selected: () => boolean;
|
||||||
|
get_sensitive: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function action(run: () => void, selected: () => boolean = () => false,
|
||||||
|
sensitive: () => boolean = () => true): NativeAction {
|
||||||
|
return { __action__: true, __call__: () => { run(); return null; }, get_selected: selected, get_sensitive: sensitive };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isNativeAction(x: any): x is NativeAction {
|
||||||
|
return !!x && typeof x === "object" && x.__action__ === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function musicRoomClass(audio: AudioSystem): PyClass {
|
||||||
|
const room = new PyClass("MusicRoom", [OBJECT]);
|
||||||
|
const set = (name: string, fn: (self: PyInstance, ...a: any[]) => any) => room.attrs.set(name, nativeMethod(fn));
|
||||||
|
|
||||||
|
const unlocked = (self: PyInstance, file: string): boolean => {
|
||||||
|
const check = self.cls.find("is_unlocked")?.value;
|
||||||
|
return truthy(typeof check === "function" ? check(self, file) : null);
|
||||||
|
};
|
||||||
|
const playlist = (self: PyInstance): string[] =>
|
||||||
|
(self.attrs.filenames as string[]).filter((f) => unlocked(self, f));
|
||||||
|
const channel = (self: PyInstance) => String(self.attrs.channel);
|
||||||
|
|
||||||
|
const play = (self: PyInstance, file: string) => {
|
||||||
|
const a = self.attrs;
|
||||||
|
a.current = file;
|
||||||
|
a.heard.push(file);
|
||||||
|
audio.play(channel(self), [file], { loop: false, fadein: a.fadein, fadeout: a.fadeout });
|
||||||
|
audio.onFinished.set(channel(self), () => advance(self));
|
||||||
|
};
|
||||||
|
const stop = (self: PyInstance) => {
|
||||||
|
self.attrs.current = null;
|
||||||
|
audio.onFinished.delete(channel(self));
|
||||||
|
audio.stop(channel(self), self.attrs.fadeout);
|
||||||
|
const stopAction = self.attrs.stop_action;
|
||||||
|
if (stopAction) (callableOf(stopAction) ?? (() => null))();
|
||||||
|
};
|
||||||
|
const step = (self: PyInstance, direction: 1 | -1, wrap: boolean) => {
|
||||||
|
const list = playlist(self);
|
||||||
|
if (!list.length) return;
|
||||||
|
const at = list.indexOf(self.attrs.current);
|
||||||
|
if (self.attrs.shuffle && list.length > 1) {
|
||||||
|
const others = list.filter((f) => f !== self.attrs.current);
|
||||||
|
return play(self, others[Math.floor(Math.random() * others.length)]);
|
||||||
|
}
|
||||||
|
const next = at + direction;
|
||||||
|
if (next >= 0 && next < list.length) return play(self, list[next]);
|
||||||
|
if (wrap) return play(self, list[direction > 0 ? 0 : list.length - 1]);
|
||||||
|
stop(self);
|
||||||
|
};
|
||||||
|
const advance = (self: PyInstance) => {
|
||||||
|
if (self.attrs.single_track) {
|
||||||
|
if (self.attrs.loop) play(self, self.attrs.current);
|
||||||
|
else stop(self);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
step(self, 1, !!self.attrs.loop);
|
||||||
|
};
|
||||||
|
|
||||||
|
set("__init__", (self, ...given) => {
|
||||||
|
const kw = given.length && given[given.length - 1]?.__kw ? given.pop().__kw : {};
|
||||||
|
const [chan, fadeout, fadein, loop, single, shuffle, stopAction] = given;
|
||||||
|
Object.assign(self.attrs, {
|
||||||
|
channel: chan ?? kw.channel ?? "music", fadeout: fadeout ?? kw.fadeout ?? 0, fadein: fadein ?? kw.fadein ?? 0,
|
||||||
|
loop: loop ?? kw.loop ?? true, single_track: single ?? kw.single_track ?? false,
|
||||||
|
shuffle: shuffle ?? kw.shuffle ?? false, stop_action: stopAction ?? kw.stop_action ?? null,
|
||||||
|
filenames: [], always: new PyDict(), actions: new PyDict(), heard: [], current: null,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
set("add", (self, file: string, ...rest) => {
|
||||||
|
const kw = rest.length && rest[rest.length - 1]?.__kw ? rest.pop().__kw : {};
|
||||||
|
const always = rest[0] ?? kw.always_unlocked ?? false;
|
||||||
|
self.attrs.filenames.push(file);
|
||||||
|
(self.attrs.always as PyDict).set(file, truthy(always));
|
||||||
|
(self.attrs.actions as PyDict).set(file, rest[1] ?? kw.action ?? null);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
set("is_unlocked", (self, file: string) =>
|
||||||
|
truthy((self.attrs.always as PyDict).get(file)) || self.attrs.heard.includes(file));
|
||||||
|
set("get_playlist", (self) => playlist(self));
|
||||||
|
set("Play", (self, file: string | null = null) => {
|
||||||
|
const target = () => file ?? self.attrs.current ?? playlist(self)[0] ?? null;
|
||||||
|
return action(() => { const f = target(); if (f) play(self, f); },
|
||||||
|
() => file !== null && self.attrs.current === file && audio.current(channel(self)) !== null,
|
||||||
|
() => file === null || unlocked(self, file));
|
||||||
|
});
|
||||||
|
set("Stop", (self) => action(() => stop(self), () => audio.current(channel(self)) === null));
|
||||||
|
set("TogglePause", (self) => action(() => audio.setPaused(channel(self), !audio.paused(channel(self))),
|
||||||
|
() => audio.paused(channel(self))));
|
||||||
|
set("Next", (self) => action(() => step(self, 1, true)));
|
||||||
|
set("Previous", (self) => action(() => step(self, -1, true)));
|
||||||
|
set("RandomPlay", (self) => action(() => {
|
||||||
|
const list = playlist(self);
|
||||||
|
if (list.length) play(self, list[Math.floor(Math.random() * list.length)]);
|
||||||
|
}));
|
||||||
|
for (const [name, key] of [["Loop", "loop"], ["SingleTrack", "single_track"], ["Shuffle", "shuffle"]] as const) {
|
||||||
|
set(`Set${name}`, (self, value: any) => action(() => { self.attrs[key] = truthy(value); }, () => truthy(self.attrs[key]) === truthy(value)));
|
||||||
|
set(`Toggle${name}`, (self) => action(() => { self.attrs[key] = !truthy(self.attrs[key]); }, () => truthy(self.attrs[key])));
|
||||||
|
}
|
||||||
|
set("UnlockAll", (self) => action(() => { for (const f of self.attrs.filenames) (self.attrs.always as PyDict).set(f, true); }));
|
||||||
|
set("LockAll", (self) => action(() => { (self.attrs.always as PyDict).map.clear(); self.attrs.heard = []; }));
|
||||||
|
return registerNativeClass(room);
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ function key(k: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class JumpSignal {
|
export class JumpSignal {
|
||||||
constructor(public kind: "jump" | "call", public target: string) {}
|
constructor(public kind: "jump" | "call" | "return", public target: string) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
class BreakSignal {}
|
class BreakSignal {}
|
||||||
@@ -54,7 +54,7 @@ export type Builtin = ((...args: any[]) => any) & { __py?: true };
|
|||||||
// `class` statements of the game become PyClass objects. Instances keep their data in
|
// `class` statements of the game become PyClass objects. Instances keep their data in
|
||||||
// `attrs`, so a plain JSON round trip (saves) restores them given the class registry.
|
// `attrs`, so a plain JSON round trip (saves) restores them given the class registry.
|
||||||
|
|
||||||
const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__"]);
|
const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__"]);
|
||||||
const MAX_CALL_DEPTH = 150;
|
const MAX_CALL_DEPTH = 150;
|
||||||
|
|
||||||
export class PyClass {
|
export class PyClass {
|
||||||
@@ -98,6 +98,90 @@ class PyDescriptor {
|
|||||||
export const OBJECT = new PyClass("object", []);
|
export const OBJECT = new PyClass("object", []);
|
||||||
const classRegistry = new Map<string, PyClass>();
|
const classRegistry = new Map<string, PyClass>();
|
||||||
|
|
||||||
|
export function registerNativeClass(cls: PyClass): PyClass {
|
||||||
|
classRegistry.set(cls.name, cls);
|
||||||
|
return cls;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A method written in TypeScript: bound to the instance like a game-defined one. */
|
||||||
|
export function nativeMethod<F extends (...args: any[]) => any>(fn: F): F {
|
||||||
|
(fn as any).__method = true;
|
||||||
|
return fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classes the player provides itself because the game's own version is a Ren'Py Displayable written
|
||||||
|
* against the render API (the converter drops the game's class of the same name).
|
||||||
|
*
|
||||||
|
* ColorPicker keeps the chosen colour and the slot it belongs to; the game's wardrobe uses it as a plain
|
||||||
|
* state holder (`picker.set_color(c)`, `picker.color_slot = slot`) and draws its own sliders.
|
||||||
|
*/
|
||||||
|
export function nativeClasses(): Record<string, PyClass> {
|
||||||
|
const picker = new PyClass("ColorPicker", [OBJECT]);
|
||||||
|
const hsv = (color: any): [number, number, number] | null => {
|
||||||
|
const text = typeof color === "string" ? color : color?.hexcode;
|
||||||
|
const m = typeof text === "string" ? /^#([0-9a-f]{3}|[0-9a-f]{6})/i.exec(text) : null;
|
||||||
|
if (!m) return null;
|
||||||
|
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join("") : m[1];
|
||||||
|
const [r, g, b] = [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
|
||||||
|
const max = Math.max(r, g, b);
|
||||||
|
const d = max - Math.min(r, g, b);
|
||||||
|
const h = d === 0 ? 0 : max === r ? ((g - b) / d + 6) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||||
|
return [h / 6, max === 0 ? 0 : d / max, max];
|
||||||
|
};
|
||||||
|
picker.attrs.set("__init__", nativeMethod((self: PyInstance, ...given: any[]) => {
|
||||||
|
const kw = given.length && given[given.length - 1]?.__kw ? given.pop().__kw : {};
|
||||||
|
const [xsize, ysize, startColor, fourCorners, saved, lastSaved] = given;
|
||||||
|
const a = self.attrs;
|
||||||
|
a.xsize = xsize ?? kw.xsize ?? 100;
|
||||||
|
a.ysize = ysize ?? kw.ysize ?? 100;
|
||||||
|
a.saved_colors = saved ?? kw.saved_colors ?? new PyDict();
|
||||||
|
a.last_saved_color = lastSaved ?? kw.last_saved_color ?? null;
|
||||||
|
a.color_slot = a.last_saved_color;
|
||||||
|
a.mouseup_callback = kw.mouseup_callback ?? null;
|
||||||
|
a.four_corners = fourCorners ?? kw.four_corners ?? null;
|
||||||
|
a.hue_rotation = 0;
|
||||||
|
a.dragging = false;
|
||||||
|
const start = startColor ?? kw.start_color ?? (a.four_corners ? a.four_corners[0] : "#f00");
|
||||||
|
(picker.attrs.get("set_color") as any)(self, start);
|
||||||
|
(picker.attrs.get("save_color") as any)(self, a.last_saved_color);
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
picker.attrs.set("set_color", nativeMethod((self: PyInstance, color: any) => {
|
||||||
|
const a = self.attrs;
|
||||||
|
a.color = color;
|
||||||
|
a.dragging = false;
|
||||||
|
const h = a.four_corners ? null : hsv(color);
|
||||||
|
a.selector_xpos = h ? h[1] : 1;
|
||||||
|
a.selector_ypos = h ? 1 - h[2] : 0;
|
||||||
|
a.hue_rotation = h ? h[0] : 0;
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
picker.attrs.set("save_color", nativeMethod((self: PyInstance, key: any) => {
|
||||||
|
(self.attrs.saved_colors as PyDict).set(key, self.attrs.color);
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
picker.attrs.set("set_saved_color", nativeMethod((self: PyInstance, key: any, color: any) => {
|
||||||
|
(self.attrs.saved_colors as PyDict).set(key, color);
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
picker.attrs.set("get_color", nativeMethod((self: PyInstance, key: any) => {
|
||||||
|
const saved = self.attrs.saved_colors as PyDict;
|
||||||
|
return saved.has(key) ? saved.get(key) : "#000";
|
||||||
|
}));
|
||||||
|
picker.attrs.set("swap_to_saved_color", nativeMethod((self: PyInstance, key: any) => {
|
||||||
|
const saved = self.attrs.saved_colors as PyDict;
|
||||||
|
(picker.attrs.get("set_color") as any)(self, saved.has(key) ? saved.get(key) : "#000");
|
||||||
|
self.attrs.last_saved_color = key;
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
classRegistry.set(picker.name, picker);
|
||||||
|
// base class of the actions a game defines itself (`class Mine(Action): def __call__(self): ...`)
|
||||||
|
const base = new PyClass("Action", [OBJECT]);
|
||||||
|
classRegistry.set(base.name, base);
|
||||||
|
return { ColorPicker: picker, Action: base };
|
||||||
|
}
|
||||||
|
|
||||||
/** Builtin exception hierarchy; ``args`` holds the constructor arguments. */
|
/** Builtin exception hierarchy; ``args`` holds the constructor arguments. */
|
||||||
export const BASE_EXCEPTION = new PyClass("BaseException", [OBJECT]);
|
export const BASE_EXCEPTION = new PyClass("BaseException", [OBJECT]);
|
||||||
BASE_EXCEPTION.attrs.set("__init__", (self: PyInstance, ...args: any[]) => {
|
BASE_EXCEPTION.attrs.set("__init__", (self: PyInstance, ...args: any[]) => {
|
||||||
@@ -160,8 +244,47 @@ export function truthy(v: any): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The function behind a callable value: a function, or an instance whose class defines __call__. */
|
||||||
|
export function callableOf(x: any): ((...args: any[]) => any) | null {
|
||||||
|
if (typeof x === "function") return x;
|
||||||
|
if (x instanceof PyInstance) {
|
||||||
|
const found = x.cls.find("__call__");
|
||||||
|
if (found && typeof found.value === "function") return (...args: any[]) => found.value(x, ...args);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every instance reachable from the game's variables (what `gc.get_objects()` shows a game). */
|
||||||
|
export function liveObjects(root: any): any[] {
|
||||||
|
const seen = new Set<any>();
|
||||||
|
const out: any[] = [];
|
||||||
|
const stack: any[] = [root];
|
||||||
|
while (stack.length) {
|
||||||
|
const value = stack.pop();
|
||||||
|
if (value === null || typeof value !== "object" || seen.has(value)) continue;
|
||||||
|
seen.add(value);
|
||||||
|
if (value instanceof PyInstance) {
|
||||||
|
out.push(value);
|
||||||
|
stack.push(...Object.values(value.attrs));
|
||||||
|
} else if (Array.isArray(value)) {
|
||||||
|
stack.push(...value);
|
||||||
|
} else if (value instanceof PyDict) {
|
||||||
|
for (const [k, v] of value.map.entries()) stack.push(k, v);
|
||||||
|
} else if (!(value instanceof PyClass)) {
|
||||||
|
stack.push(...Object.values(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export function pyEq(a: any, b: any): boolean {
|
export function pyEq(a: any, b: any): boolean {
|
||||||
if (a === b) return true;
|
if (a === b) return true;
|
||||||
|
for (const [self, other] of [[a, b], [b, a]]) {
|
||||||
|
if (self instanceof PyInstance && activePython) {
|
||||||
|
const found = self.cls.find("__eq__");
|
||||||
|
if (found && typeof found.value === "function") return truthy(found.value(self, other));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (a === null || b === null || a === undefined || b === undefined) return a == b;
|
if (a === null || b === null || a === undefined || b === undefined) return a == b;
|
||||||
if (Array.isArray(a) && Array.isArray(b))
|
if (Array.isArray(a) && Array.isArray(b))
|
||||||
return a.length === b.length && a.every((x, i) => pyEq(x, b[i]));
|
return a.length === b.length && a.every((x, i) => pyEq(x, b[i]));
|
||||||
@@ -287,6 +410,13 @@ function safeProperty(name: any): boolean {
|
|||||||
!["constructor", "prototype", "caller", "callee", "arguments"].includes(name);
|
!["constructor", "prototype", "caller", "callee", "arguments"].includes(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Interpreted classes keep attributes in isolated maps/records. Names such as
|
||||||
|
// `arguments` are ordinary game data there; host functions retain the stricter guard.
|
||||||
|
function safePythonProperty(object: any, name: any): boolean {
|
||||||
|
return safeProperty(name) || (name === "arguments"
|
||||||
|
&& (object instanceof PyInstance || object instanceof PyClass || object instanceof SuperProxy));
|
||||||
|
}
|
||||||
|
|
||||||
function methodOf(obj: any, name: string): any {
|
function methodOf(obj: any, name: string): any {
|
||||||
if (Array.isArray(obj)) {
|
if (Array.isArray(obj)) {
|
||||||
const m: Record<string, Builtin> = {
|
const m: Record<string, Builtin> = {
|
||||||
@@ -453,7 +583,7 @@ export class Python {
|
|||||||
case "attr": {
|
case "attr": {
|
||||||
const o = this.eval(e.o);
|
const o = this.eval(e.o);
|
||||||
if (o instanceof PyInstance || o instanceof PyClass || o instanceof SuperProxy) {
|
if (o instanceof PyInstance || o instanceof PyClass || o instanceof SuperProxy) {
|
||||||
if (!safeProperty(e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null;
|
if (!safePythonProperty(o, e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null;
|
||||||
const v = this.attrOf(o, e.a);
|
const v = this.attrOf(o, e.a);
|
||||||
if (v === undefined) this.warnOnce(`у ${o instanceof PyInstance ? o.cls.name : "класса"} нет атрибута ${e.a}`);
|
if (v === undefined) this.warnOnce(`у ${o instanceof PyInstance ? o.cls.name : "класса"} нет атрибута ${e.a}`);
|
||||||
return v === undefined ? null : v;
|
return v === undefined ? null : v;
|
||||||
@@ -585,6 +715,10 @@ export class Python {
|
|||||||
}
|
}
|
||||||
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
||||||
if (f instanceof PyClass) return this.instantiate(f, call);
|
if (f instanceof PyClass) return this.instantiate(f, call);
|
||||||
|
if (f instanceof PyInstance) {
|
||||||
|
const bound = callableOf(f);
|
||||||
|
if (bound) return bound(...call);
|
||||||
|
}
|
||||||
if (f && typeof f === "object" && typeof f.__call__ === "function") return f.__call__(...call); // screen actions
|
if (f && typeof f === "object" && typeof f.__call__ === "function") return f.__call__(...call); // screen actions
|
||||||
if (typeof f !== "function") {
|
if (typeof f !== "function") {
|
||||||
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
||||||
@@ -615,7 +749,7 @@ export class Python {
|
|||||||
}
|
}
|
||||||
case "attr": {
|
case "attr": {
|
||||||
const o = this.eval(t.o);
|
const o = this.eval(t.o);
|
||||||
if (!safeProperty(t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
|
if (!safePythonProperty(o, t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
|
||||||
if (o instanceof PyInstance) {
|
if (o instanceof PyInstance) {
|
||||||
const found = o.cls.find(t.a);
|
const found = o.cls.find(t.a);
|
||||||
if (found?.value instanceof PyDescriptor && found.value.kind === "property") {
|
if (found?.value instanceof PyDescriptor && found.value.kind === "property") {
|
||||||
@@ -1118,6 +1252,7 @@ export class Python {
|
|||||||
}
|
}
|
||||||
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
const call = Object.keys(kw).length ? [...args, { __kw: kw }] : args;
|
||||||
if (f instanceof PyClass) return yield* this.instantiateG(f, call);
|
if (f instanceof PyClass) return yield* this.instantiateG(f, call);
|
||||||
|
if (f instanceof PyInstance) f = callableOf(f) ?? f;
|
||||||
if (typeof f !== "function") {
|
if (typeof f !== "function") {
|
||||||
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
this.warnOnce(`вызов не-функции (${e.f.id ?? e.f.a ?? "?"})`);
|
||||||
return null;
|
return null;
|
||||||
@@ -1320,7 +1455,7 @@ export function userBuiltins(py: Python): Record<string, any> {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
const get = (o: any, name: string): any => {
|
const get = (o: any, name: string): any => {
|
||||||
if (typeof name !== "string" || (!safeProperty(name) && !SUPPORTED_DUNDERS.has(name))) return undefined;
|
if (typeof name !== "string" || (!safePythonProperty(o, name) && !SUPPORTED_DUNDERS.has(name))) return undefined;
|
||||||
if (o instanceof PyInstance || o instanceof PyClass) return py.attrOf(o, name);
|
if (o instanceof PyInstance || o instanceof PyClass) return py.attrOf(o, name);
|
||||||
if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined;
|
if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined;
|
||||||
if (o === null || o === undefined || typeof o === "function") return undefined;
|
if (o === null || o === undefined || typeof o === "function") return undefined;
|
||||||
@@ -1359,10 +1494,10 @@ export function userBuiltins(py: Python): Record<string, any> {
|
|||||||
throw new Error(`нет атрибута ${a[1]}`);
|
throw new Error(`нет атрибута ${a[1]}`);
|
||||||
},
|
},
|
||||||
setattr: (o: any, name: string, value: any) => {
|
setattr: (o: any, name: string, value: any) => {
|
||||||
if (!safeProperty(name)) throw new Error("Служебный атрибут недоступен");
|
if (!safePythonProperty(o, name)) throw new Error("Служебный атрибут недоступен");
|
||||||
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
||||||
},
|
},
|
||||||
callable: (x: any) => typeof x === "function" || x instanceof PyClass,
|
callable: (x: any) => typeof x === "function" || x instanceof PyClass || callableOf(x) !== null,
|
||||||
map: (f: any, xs: any) => [...iterate(xs)].map((x) => f(x)),
|
map: (f: any, xs: any) => [...iterate(xs)].map((x) => f(x)),
|
||||||
filter: (f: any, xs: any) => [...iterate(xs)].filter((x) => (f === null ? truthy(x) : truthy(f(x)))),
|
filter: (f: any, xs: any) => [...iterate(xs)].filter((x) => (f === null ? truthy(x) : truthy(f(x)))),
|
||||||
sorted: (...a: any[]) => {
|
sorted: (...a: any[]) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
||||||
import { ColorMatrix, matrixFilter } from "./colormatrix";
|
import { ColorMatrix, matrixFilter } from "./colormatrix";
|
||||||
|
import { movieDisplayable } from "./movie";
|
||||||
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>> = {
|
||||||
@@ -138,6 +139,9 @@ export class Renderer {
|
|||||||
|
|
||||||
// --- displayables ------------------------------------------------------------
|
// --- displayables ------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Parameters of the ATL transform that is building a picture right now. */
|
||||||
|
private frame: Map<string, any> | null = null;
|
||||||
|
|
||||||
private build(def: ImageDef, depth = 0): HTMLElement {
|
private build(def: ImageDef, depth = 0): HTMLElement {
|
||||||
if (depth > 8) return document.createElement("div");
|
if (depth > 8) return document.createElement("div");
|
||||||
if (def.ref) {
|
if (def.ref) {
|
||||||
@@ -146,7 +150,7 @@ export class Renderer {
|
|||||||
}
|
}
|
||||||
if (def.expr) {
|
if (def.expr) {
|
||||||
// an image defined by a Python expression: its value names a picture, evaluated when shown
|
// an image defined by a Python expression: its value names a picture, evaluated when shown
|
||||||
const value = this.host.evalExpr(def.expr);
|
const value = this.host.evalExpr(def.expr, this.frame ?? undefined);
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const ref = this.host.resolveRef(value);
|
const ref = this.host.resolveRef(value);
|
||||||
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
|
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
|
||||||
@@ -165,6 +169,17 @@ export class Renderer {
|
|||||||
d.style.height = `${this.height}px`;
|
d.style.height = `${this.height}px`;
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
if (def.alphaMask) {
|
||||||
|
const picture = this.build(def.alphaMask.img, depth + 1);
|
||||||
|
const maskEl = this.build(def.alphaMask.mask, depth + 1);
|
||||||
|
const file = (maskEl instanceof HTMLImageElement ? maskEl : maskEl.querySelector("img"))?.src;
|
||||||
|
if (file) {
|
||||||
|
picture.style.maskImage = `url("${file}")`;
|
||||||
|
picture.style.maskSize = "100% 100%";
|
||||||
|
picture.style.maskRepeat = "no-repeat";
|
||||||
|
}
|
||||||
|
return picture;
|
||||||
|
}
|
||||||
if (def.composite) {
|
if (def.composite) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
d.style.position = "relative";
|
d.style.position = "relative";
|
||||||
@@ -182,14 +197,10 @@ export class Renderer {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
if (def.movie) {
|
if (def.movie) {
|
||||||
const v = document.createElement("video");
|
|
||||||
const src = this.host.url(def.movie);
|
const src = this.host.url(def.movie);
|
||||||
if (src) v.src = src;
|
const mask = def.movieMask ? this.host.url(def.movieMask) : null;
|
||||||
v.autoplay = true;
|
if (!src || (def.movieMask && !mask)) throw new Error(`Не найдено видео или маска: ${def.movie}`);
|
||||||
v.loop = true;
|
return movieDisplayable(src, mask, def.movieLoop ?? true);
|
||||||
v.muted = true;
|
|
||||||
v.playsInline = true;
|
|
||||||
return v;
|
|
||||||
}
|
}
|
||||||
if (def.text !== undefined || def.textExpr) {
|
if (def.text !== undefined || def.textExpr) {
|
||||||
const d = document.createElement("div");
|
const d = document.createElement("div");
|
||||||
@@ -263,7 +274,14 @@ export class Renderer {
|
|||||||
props: (name) => this.host.transform(name),
|
props: (name) => this.host.transform(name),
|
||||||
apply: (p) => this.style(el, { ...p }),
|
apply: (p) => this.style(el, { ...p }),
|
||||||
eval: (expr, frame) => this.host.evalExpr(expr, frame),
|
eval: (expr, frame) => this.host.evalExpr(expr, frame),
|
||||||
image: (img, transition) => this.swap(el, img, transition),
|
image: (img, transition, frame) => {
|
||||||
|
this.frame = frame ?? null; // the transform's parameters, for pictures computed from them
|
||||||
|
try {
|
||||||
|
this.swap(el, img, transition);
|
||||||
|
} finally {
|
||||||
|
this.frame = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
contains: (img) => {
|
contains: (img) => {
|
||||||
const child = this.build(img);
|
const child = this.build(img);
|
||||||
child.style.position = "absolute";
|
child.style.position = "absolute";
|
||||||
@@ -288,16 +306,16 @@ export class Renderer {
|
|||||||
box.style.position = "relative";
|
box.style.position = "relative";
|
||||||
const frames = def.frames!;
|
const frames = def.frames!;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let started = false;
|
|
||||||
box.append(this.build(frames[0].img, depth + 1));
|
box.append(this.build(frames[0].img, depth + 1));
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (box.isConnected) started = true;
|
|
||||||
else if (started) return;
|
|
||||||
const d = frames[i].d ?? 0;
|
const d = frames[i].d ?? 0;
|
||||||
const last = i === frames.length - 1;
|
const last = i === frames.length - 1;
|
||||||
if (!d || (last && !def.repeat)) return;
|
if (!d || (last && !def.repeat)) return;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
if (started && !box.isConnected) return;
|
// The first tick is queued before the caller attaches the element.
|
||||||
|
// By the next tick a detached/replaced preview must stop, even if it
|
||||||
|
// was removed before any timer observed it attached.
|
||||||
|
if (!box.isConnected) return;
|
||||||
i = last ? 0 : i + 1;
|
i = last ? 0 : i + 1;
|
||||||
box.replaceChildren(this.build(frames[i].img, depth + 1));
|
box.replaceChildren(this.build(frames[i].img, depth + 1));
|
||||||
tick();
|
tick();
|
||||||
@@ -376,15 +394,27 @@ export class Renderer {
|
|||||||
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
|
const anchor = (v: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? `${v}px` : `${v * 100}%`);
|
||||||
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
|
el.style.left = `calc(${pos(p.xpos, this.width)} + ${p.xoffset ?? 0}px)`;
|
||||||
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
|
el.style.top = `calc(${pos(p.ypos, this.height)} + ${p.yoffset ?? 0}px)`;
|
||||||
const ax = anchor(p.xanchor);
|
// `size`/`xsize`/`ysize` stretch the picture; a missing side keeps the picture's proportions
|
||||||
const ay = anchor(p.yanchor);
|
const [sx, sy] = p.xysize ?? p.size ?? [p.xsize, p.ysize];
|
||||||
el.style.transformOrigin = `${ax} ${ay}`;
|
if (typeof sx === "number") el.style.width = `${sx}px`;
|
||||||
|
if (typeof sy === "number") el.style.height = `${sy}px`;
|
||||||
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
|
const zx = (p.zoom ?? 1) * (p.xzoom ?? 1);
|
||||||
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
|
const zy = (p.zoom ?? 1) * (p.yzoom ?? 1);
|
||||||
el.style.transform = `translate(-${ax}, -${ay}) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
|
const crop = Array.isArray(p.crop) && p.crop.length === 4 ? (p.crop as number[]).map(Number) : null;
|
||||||
// translate(-%) must use the element box, so negative percentages of px anchors are written as is
|
let ax = anchor(p.xanchor);
|
||||||
if (ax.endsWith("px") || ay.endsWith("px"))
|
let ay = anchor(p.yanchor);
|
||||||
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})`;
|
if (crop) {
|
||||||
|
// only the cropped rectangle is shown, and anchors/positions refer to it
|
||||||
|
const [cx, cy, cw, ch] = crop;
|
||||||
|
const frac = (v: number, size: number) => (Number.isInteger(v) && Math.abs(v) > 1 ? v : v * size);
|
||||||
|
ax = `${cx + frac(p.xanchor, cw)}px`;
|
||||||
|
ay = `${cy + frac(p.yanchor, ch)}px`;
|
||||||
|
el.style.clipPath = `inset(${cy}px calc(100% - ${cx + cw}px) calc(100% - ${cy + ch}px) ${cx}px)`;
|
||||||
|
} else {
|
||||||
|
el.style.clipPath = "";
|
||||||
|
}
|
||||||
|
el.style.transformOrigin = `${ax} ${ay}`;
|
||||||
|
el.style.transform = `translate(calc(-1 * ${ax}), calc(-1 * ${ay})) scale(${zx}, ${zy})` + (p.rotate ? ` rotate(${p.rotate}deg)` : "");
|
||||||
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
|
if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
|
||||||
const filters: string[] = [];
|
const filters: string[] = [];
|
||||||
if (p.filter) filters.push(`${p.filter}(1)`);
|
if (p.filter) filters.push(`${p.filter}(1)`);
|
||||||
|
|||||||
+334
-14
@@ -3,7 +3,12 @@
|
|||||||
|
|
||||||
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
import { AtlRunner, bindArguments, type AtlBlock } from "./atl";
|
||||||
import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix";
|
import { ColorMatrix, colorMatrices, legacyMatrices, matrixFilter } from "./colormatrix";
|
||||||
import { PyDict, pyStr, truthy, type Python } from "./python";
|
import type { AudioSystem } from "./audio";
|
||||||
|
import { isNativeAction } from "./musicroom";
|
||||||
|
import { galleryClass } from "./gallery";
|
||||||
|
import { parseExpression } from "./pyparse";
|
||||||
|
import { movieDisplayable } from "./movie";
|
||||||
|
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
|
||||||
import type { AtSpec, Expr } from "./types";
|
import type { AtSpec, Expr } from "./types";
|
||||||
|
|
||||||
export interface ScreenDef {
|
export interface ScreenDef {
|
||||||
@@ -43,6 +48,10 @@ export interface ScreenHost {
|
|||||||
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;
|
||||||
|
audio: AudioSystem;
|
||||||
|
/** Set the volume (0..1) of a mixer: "music", "sfx" or "voice". */
|
||||||
|
setMixer(name: string, value: number): void;
|
||||||
|
error?(message: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Props = Record<string, any>;
|
type Props = Record<string, any>;
|
||||||
@@ -62,6 +71,8 @@ export class Displayable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class BarValue {
|
export class BarValue {
|
||||||
|
/** Changes by itself (a playing position): the screen redraws it while it is shown. */
|
||||||
|
live = false;
|
||||||
constructor(public value: () => number, public range: () => number, public set?: (v: number) => void) {}
|
constructor(public value: () => number, public range: () => number, public set?: (v: number) => void) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +102,7 @@ interface Instance {
|
|||||||
|
|
||||||
interface Ctx {
|
interface Ctx {
|
||||||
/** How the parent places this child. */
|
/** How the parent places this child. */
|
||||||
layout: "abs" | "stack" | "col" | "row" | "grid";
|
layout: "abs" | "stack" | "col" | "row" | "grid" | "side";
|
||||||
prefix: string;
|
prefix: string;
|
||||||
inst: Instance;
|
inst: Instance;
|
||||||
}
|
}
|
||||||
@@ -108,6 +119,9 @@ export class ScreenManager {
|
|||||||
private counter = 0;
|
private counter = 0;
|
||||||
private pending = 0;
|
private pending = 0;
|
||||||
private rendering: Instance | null = null;
|
private rendering: Instance | null = null;
|
||||||
|
private interactionReturn: ((value: any) => void) | null = null;
|
||||||
|
private suspended = false;
|
||||||
|
private galleryClose: (() => void) | null = null;
|
||||||
tooltip: any = "";
|
tooltip: any = "";
|
||||||
|
|
||||||
constructor(private host: ScreenHost, private screens: Record<string, ScreenDef>,
|
constructor(private host: ScreenHost, private screens: Record<string, ScreenDef>,
|
||||||
@@ -120,6 +134,25 @@ export class ScreenManager {
|
|||||||
return name in this.screens;
|
return name in this.screens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** ui.interact waits on Return from an already shown screen, without hiding it. */
|
||||||
|
interact(done: (value: any) => void): () => void {
|
||||||
|
if (this.interactionReturn) throw new Error("Уже выполняется ui.interact");
|
||||||
|
const answer = (value: any) => {
|
||||||
|
if (this.interactionReturn !== answer) return;
|
||||||
|
this.interactionReturn = null;
|
||||||
|
done(value);
|
||||||
|
};
|
||||||
|
this.interactionReturn = answer;
|
||||||
|
return () => { if (this.interactionReturn === answer) this.interactionReturn = null; };
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend(): () => void {
|
||||||
|
const previous = this.suspended;
|
||||||
|
this.suspended = true;
|
||||||
|
this.layer.style.visibility = "hidden";
|
||||||
|
return () => { this.suspended = previous; this.layer.style.visibility = previous ? "hidden" : ""; };
|
||||||
|
}
|
||||||
|
|
||||||
// --- lifecycle -------------------------------------------------------------------
|
// --- lifecycle -------------------------------------------------------------------
|
||||||
|
|
||||||
show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void } = {}) {
|
show(name: string, args: any[] = [], kw: Props = {}, opts: { call?: boolean; onReturn?: (v: any) => void } = {}) {
|
||||||
@@ -162,6 +195,10 @@ export class ScreenManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
this.suspended = false;
|
||||||
|
this.layer.style.visibility = "";
|
||||||
|
this.interactionReturn = null;
|
||||||
|
this.galleryClose?.();
|
||||||
for (const inst of [...this.shown.values()]) this.dispose(inst, true);
|
for (const inst of [...this.shown.values()]) this.dispose(inst, true);
|
||||||
this.tooltip = "";
|
this.tooltip = "";
|
||||||
}
|
}
|
||||||
@@ -198,9 +235,10 @@ export class ScreenManager {
|
|||||||
|
|
||||||
/** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */
|
/** Re-evaluate every screen; coalesced so many state changes cost one rebuild per frame. */
|
||||||
refresh() {
|
refresh() {
|
||||||
if (this.pending || !this.shown.size) return;
|
if (this.suspended || this.pending || !this.shown.size) return;
|
||||||
this.pending = requestAnimationFrame(() => {
|
this.pending = requestAnimationFrame(() => {
|
||||||
this.pending = 0;
|
this.pending = 0;
|
||||||
|
if (this.suspended) return;
|
||||||
for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst);
|
for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,10 +361,15 @@ export class ScreenManager {
|
|||||||
} else if (node.c === "use") {
|
} else if (node.c === "use") {
|
||||||
this.use(node, ctx, out);
|
this.use(node, ctx, out);
|
||||||
} else if (node.c === "python") {
|
} else if (node.c === "python") {
|
||||||
|
// `renpy.ui.imagebutton(...)` called from game code adds its widget to this screen
|
||||||
|
const outer = this.uiTarget;
|
||||||
|
this.uiTarget = { out, ctx };
|
||||||
try {
|
try {
|
||||||
this.host.py.exec(node.code);
|
this.host.py.exec(node.code);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.host.py.warnOnce(`screen python: ${(err as Error).message}`);
|
this.host.py.warnOnce(`screen python: ${(err as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.uiTarget = outer;
|
||||||
}
|
}
|
||||||
} else if (node.c === "key") {
|
} else if (node.c === "key") {
|
||||||
this.keyBinding(node, ctx);
|
this.keyBinding(node, ctx);
|
||||||
@@ -341,10 +384,15 @@ export class ScreenManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private use(node: any, ctx: Ctx, out: HTMLElement[]) {
|
private use(node: any, ctx: Ctx, out: HTMLElement[]) {
|
||||||
const def = this.screens[node.name];
|
const name = node.nameExpr ? pyStr(this.ev(node.nameExpr)) : node.name;
|
||||||
|
const def = this.screens[name];
|
||||||
if (!def) return;
|
if (!def) return;
|
||||||
const args = node.args.map((a: Expr) => this.ev(a));
|
const args = node.args.map((a: Expr) => this.ev(a));
|
||||||
const kw = Object.fromEntries(Object.entries(node.kw).map(([k, v]) => [k, this.ev(v as Expr)]));
|
const kw = Object.fromEntries(Object.entries(node.kw).map(([k, v]) => [k, this.ev(v as Expr)]));
|
||||||
|
for (const spread of node.ks ?? []) {
|
||||||
|
const values = this.ev(spread);
|
||||||
|
if (values && typeof values === "object") Object.assign(kw, values instanceof Map ? Object.fromEntries(values) : values);
|
||||||
|
}
|
||||||
const frame = new Map<string, any>();
|
const frame = new Map<string, any>();
|
||||||
def.params.forEach((p, i) => {
|
def.params.forEach((p, i) => {
|
||||||
if (Object.hasOwn(kw, p.n)) frame.set(p.n, kw[p.n]);
|
if (Object.hasOwn(kw, p.n)) frame.set(p.n, kw[p.n]);
|
||||||
@@ -477,6 +525,8 @@ export class ScreenManager {
|
|||||||
case "grid": return this.grid(node, ctx);
|
case "grid": return this.grid(node, ctx);
|
||||||
case "viewport": return this.viewport(node, ctx);
|
case "viewport": return this.viewport(node, ctx);
|
||||||
case "vpgrid": return this.vpgrid(node, ctx);
|
case "vpgrid": return this.vpgrid(node, ctx);
|
||||||
|
case "side": return this.container(node, ctx, "div", "side", ["side", "fixed"]);
|
||||||
|
case "nearrect": return this.nearRect(node, ctx);
|
||||||
case "button": return this.button(node, ctx, "div", ["button"], null);
|
case "button": return this.button(node, ctx, "div", ["button"], null);
|
||||||
case "textbutton": return this.button(node, ctx, "div", ["button"], this.ev(node.args[0]));
|
case "textbutton": return this.button(node, ctx, "div", ["button"], this.ev(node.args[0]));
|
||||||
case "imagebutton": return this.imageButton(node, ctx);
|
case "imagebutton": return this.imageButton(node, ctx);
|
||||||
@@ -502,9 +552,19 @@ export class ScreenManager {
|
|||||||
const gap = num(this.pick(props, "spacing"));
|
const gap = num(this.pick(props, "spacing"));
|
||||||
if (gap !== null) style.gap = `${gap}px`;
|
if (gap !== null) style.gap = `${gap}px`;
|
||||||
if (truthy(this.pick(props, "box_reverse"))) style.flexDirection += "-reverse";
|
if (truthy(this.pick(props, "box_reverse"))) style.flexDirection += "-reverse";
|
||||||
if (truthy(this.pick(props, "box_wrap"))) style.flexWrap = "wrap";
|
if (truthy(this.pick(props, "box_wrap"))) {
|
||||||
|
style.flexWrap = "wrap";
|
||||||
|
// between the lines of a wrapped box (`spacing` is between the items of one line)
|
||||||
|
const wrapGap = num(this.pick(props, "box_wrap_spacing"));
|
||||||
|
if (wrapGap !== null) style[layout === "col" ? "columnGap" : "rowGap"] = `${wrapGap}px`;
|
||||||
|
}
|
||||||
} else if (layout === "stack") {
|
} else if (layout === "stack") {
|
||||||
style.display = "grid";
|
style.display = "grid";
|
||||||
|
} else if (layout === "side") {
|
||||||
|
// `side "c tl r"`: the children take these places; the centre gets what the edges leave
|
||||||
|
style.display = "grid";
|
||||||
|
style.gridTemplateColumns = "auto 1fr auto";
|
||||||
|
style.gridTemplateRows = "auto 1fr auto";
|
||||||
} else if (layout === "grid") {
|
} else if (layout === "grid") {
|
||||||
style.display = "grid";
|
style.display = "grid";
|
||||||
const cols = num(this.pick(props, "cols"));
|
const cols = num(this.pick(props, "cols"));
|
||||||
@@ -520,10 +580,14 @@ export class ScreenManager {
|
|||||||
const inner: Ctx = { ...ctx, layout };
|
const inner: Ctx = { ...ctx, layout };
|
||||||
const children: HTMLElement[] = [];
|
const children: HTMLElement[] = [];
|
||||||
this.nodes(node.body, inner, children);
|
this.nodes(node.body, inner, children);
|
||||||
for (const child of children) {
|
const places = layout === "side" ? pyStr(this.ev(node.args[0]) ?? "").split(/\s+/).filter(Boolean) : [];
|
||||||
|
const SIDE_CELLS: Record<string, string> = { tl: "1 / 1", t: "1 / 2", tr: "1 / 3", l: "2 / 1", c: "2 / 2", r: "2 / 3",
|
||||||
|
bl: "3 / 1", b: "3 / 2", br: "3 / 3" };
|
||||||
|
children.forEach((child, i) => {
|
||||||
if (layout === "stack") child.style.gridArea = "1 / 1";
|
if (layout === "stack") child.style.gridArea = "1 / 1";
|
||||||
|
if (layout === "side" && SIDE_CELLS[places[i]]) child.style.gridArea = SIDE_CELLS[places[i]];
|
||||||
el.append(child);
|
el.append(child);
|
||||||
}
|
});
|
||||||
this.transforms(el, props, ctx);
|
this.transforms(el, props, ctx);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
@@ -557,10 +621,45 @@ export class ScreenManager {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The widget whose `ToggleFocus` opened a popup, and where it is (in screen coordinates). */
|
||||||
|
private focus: { id: string; rect: { x: number; y: number; w: number; h: number } } | null = null;
|
||||||
|
private activated: HTMLElement | null = null;
|
||||||
|
|
||||||
|
/** A box that appears beside the focused widget: below it, above (`prefer_top`) or on `preferred_side`. */
|
||||||
|
private nearRect(node: any, ctx: Ctx): HTMLElement {
|
||||||
|
const el = this.container(node, ctx, "div", "abs", ["fixed"]);
|
||||||
|
const props = this.resolve(node, ctx, ["fixed"]);
|
||||||
|
const rect = this.focus && this.focus.id === pyStr(this.ev(props.focus)) ? this.focus.rect : null;
|
||||||
|
el.style.position = "absolute";
|
||||||
|
if (!rect) return el;
|
||||||
|
const dx = num(this.pick(props, "xpos")) ?? 0;
|
||||||
|
const dy = num(this.pick(props, "ypos")) ?? 0;
|
||||||
|
const side = pyStr(this.pick(props, "preferred_side") ?? "");
|
||||||
|
let [x, y, shift] = [rect.x + rect.w / 2, rect.y + rect.h, "-50%, 0"];
|
||||||
|
if (truthy(this.pick(props, "prefer_top"))) [x, y, shift] = [rect.x + rect.w / 2, rect.y, "-50%, -100%"];
|
||||||
|
else if (side === "left") [x, y, shift] = [rect.x, rect.y + rect.h / 2, "-100%, -50%"];
|
||||||
|
else if (side === "right") [x, y, shift] = [rect.x + rect.w, rect.y + rect.h / 2, "0, -50%"];
|
||||||
|
el.style.left = `${x + dx}px`;
|
||||||
|
el.style.top = `${y + dy}px`;
|
||||||
|
el.style.transform = `translate(${shift})`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
private screenRect(el: HTMLElement | null): { x: number; y: number; w: number; h: number } {
|
||||||
|
const root = el?.closest(".pg-root") as HTMLElement | null;
|
||||||
|
if (!el || !root) return { x: 0, y: 0, w: 0, h: 0 };
|
||||||
|
const box = el.getBoundingClientRect();
|
||||||
|
const area = root.getBoundingClientRect();
|
||||||
|
const scale = root.offsetWidth ? area.width / root.offsetWidth : 1;
|
||||||
|
return { x: (box.left - area.left) / scale, y: (box.top - area.top) / scale, w: box.width / scale, h: box.height / scale };
|
||||||
|
}
|
||||||
|
|
||||||
private viewport(node: any, ctx: Ctx): HTMLElement {
|
private viewport(node: any, ctx: Ctx): HTMLElement {
|
||||||
const el = this.container(node, ctx, "div", "col", ["viewport"]);
|
const el = this.container(node, ctx, "div", "col", ["viewport"]);
|
||||||
el.classList.add("pg-sviewport");
|
el.classList.add("pg-sviewport");
|
||||||
const props = this.resolve(node, ctx, ["viewport"]);
|
const props = this.resolve(node, ctx, ["viewport"]);
|
||||||
|
const viewportId = this.pick(props, "id");
|
||||||
|
if (viewportId) el.dataset.viewportId = pyStr(viewportId);
|
||||||
const mode = pyStr(this.pick(props, "scrollbars") ?? "");
|
const mode = pyStr(this.pick(props, "scrollbars") ?? "");
|
||||||
el.style.overflowY = mode.includes("vertical") || this.pick(props, "mousewheel") ? "auto" : "hidden";
|
el.style.overflowY = mode.includes("vertical") || this.pick(props, "mousewheel") ? "auto" : "hidden";
|
||||||
el.style.overflowX = mode.includes("horizontal") ? "auto" : "hidden";
|
el.style.overflowX = mode.includes("horizontal") ? "auto" : "hidden";
|
||||||
@@ -600,6 +699,51 @@ export class ScreenManager {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private showGallery(pictures: any[][], transition: any) {
|
||||||
|
this.galleryClose?.();
|
||||||
|
if (!pictures.length) return;
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "pg-gallery";
|
||||||
|
Object.assign(overlay.style, { position: "absolute", inset: "0", zIndex: "9999", background: "black", display: "grid" });
|
||||||
|
const image = document.createElement("div");
|
||||||
|
Object.assign(image.style, { display: "grid", gridArea: "1 / 1", placeItems: "center" });
|
||||||
|
const controls = document.createElement("div");
|
||||||
|
Object.assign(controls.style, { gridArea: "1 / 1", alignSelf: "end", justifySelf: "center", zIndex: "1" });
|
||||||
|
let index = 0;
|
||||||
|
const close = () => { overlay.remove(); if (this.galleryClose === close) this.galleryClose = null; };
|
||||||
|
const paint = () => {
|
||||||
|
image.replaceChildren();
|
||||||
|
for (const value of pictures[index]) {
|
||||||
|
const element = this.displayable(value);
|
||||||
|
if (element) { element.style.gridArea = "1 / 1"; image.append(element); }
|
||||||
|
}
|
||||||
|
if (transition?.d && image.animate) image.animate([{ opacity: 0 }, { opacity: 1 }], { duration: transition.d * 1000 });
|
||||||
|
};
|
||||||
|
const button = (label: string, run: () => void) => {
|
||||||
|
const element = document.createElement("button");
|
||||||
|
element.textContent = label;
|
||||||
|
element.onclick = (event) => { event.stopPropagation(); run(); };
|
||||||
|
controls.append(element);
|
||||||
|
};
|
||||||
|
button("Назад", () => { index = (index + pictures.length - 1) % pictures.length; paint(); });
|
||||||
|
button("Закрыть", close);
|
||||||
|
const next = () => { if (++index >= pictures.length) close(); else paint(); };
|
||||||
|
button("Далее", next);
|
||||||
|
overlay.onclick = (event) => { event.stopPropagation(); next(); };
|
||||||
|
overlay.onkeydown = (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.key === "Escape") close();
|
||||||
|
else if (["Enter", " ", "ArrowRight"].includes(event.key)) { event.preventDefault(); next(); }
|
||||||
|
else if (event.key === "ArrowLeft") { event.preventDefault(); index = (index + pictures.length - 1) % pictures.length; paint(); }
|
||||||
|
};
|
||||||
|
overlay.tabIndex = 0;
|
||||||
|
overlay.append(image, controls);
|
||||||
|
this.layer.append(overlay);
|
||||||
|
this.galleryClose = close;
|
||||||
|
paint();
|
||||||
|
overlay.focus();
|
||||||
|
}
|
||||||
|
|
||||||
private bar(node: any, ctx: Ctx): HTMLElement {
|
private bar(node: any, ctx: Ctx): HTMLElement {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
const props = this.resolve(node, ctx, ["bar"]);
|
const props = this.resolve(node, ctx, ["bar"]);
|
||||||
@@ -616,6 +760,9 @@ export class ScreenManager {
|
|||||||
value = source.value();
|
value = source.value();
|
||||||
range = source.range();
|
range = source.range();
|
||||||
}
|
}
|
||||||
|
if (source instanceof BarValue && source.live) {
|
||||||
|
window.setTimeout(() => { if (el.isConnected) this.refresh(); }, 250);
|
||||||
|
}
|
||||||
const ratio = range > 0 ? Math.max(0, Math.min(1, value / range)) : 0;
|
const ratio = range > 0 ? Math.max(0, Math.min(1, value / range)) : 0;
|
||||||
fill.style[vertical ? "height" : "width"] = `${ratio * 100}%`;
|
fill.style[vertical ? "height" : "width"] = `${ratio * 100}%`;
|
||||||
el.append(fill);
|
el.append(fill);
|
||||||
@@ -641,6 +788,11 @@ export class ScreenManager {
|
|||||||
if (!(value instanceof Displayable)) return this.host.image(pyStr(value));
|
if (!(value instanceof Displayable)) return this.host.image(pyStr(value));
|
||||||
const [a, b] = value.args;
|
const [a, b] = value.args;
|
||||||
switch (value.kind) {
|
switch (value.kind) {
|
||||||
|
case "gallerybutton": {
|
||||||
|
if (!this.rendering) throw new Error("Кнопка Gallery должна находиться в screen");
|
||||||
|
const props = Object.fromEntries(Object.entries(value.kw).map(([key, v]) => [key, { k: "const", v }]));
|
||||||
|
return this.imageButton({ props, args: [], body: [] }, { inst: this.rendering, layout: "abs", prefix: "" });
|
||||||
|
}
|
||||||
case "crop": {
|
case "crop": {
|
||||||
const child = this.displayable(b, depth + 1);
|
const child = this.displayable(b, depth + 1);
|
||||||
return child && Array.isArray(a) ? this.cropped(child, a) : child;
|
return child && Array.isArray(a) ? this.cropped(child, a) : child;
|
||||||
@@ -710,6 +862,45 @@ export class ScreenManager {
|
|||||||
}
|
}
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
case "movie": {
|
||||||
|
if (value.kw.play !== undefined || typeof a === "string") {
|
||||||
|
const src = this.host.fileUrl(pyStr(value.kw.play ?? a));
|
||||||
|
const mask = value.kw.mask ? this.host.fileUrl(pyStr(value.kw.mask)) : null;
|
||||||
|
if (!src || (value.kw.mask && !mask)) throw new Error("Не найдено видео или маска Movie");
|
||||||
|
return movieDisplayable(src, mask, value.kw.loop !== false);
|
||||||
|
}
|
||||||
|
// the picture of the channel that plays video (renpy.music.play(..., channel="movie"))
|
||||||
|
const box = document.createElement("div");
|
||||||
|
const size = value.kw.size;
|
||||||
|
box.style.width = Array.isArray(size) ? `${size[0]}px` : "100%";
|
||||||
|
box.style.height = Array.isArray(size) ? `${size[1]}px` : "100%";
|
||||||
|
const channel = String(value.kw.channel ?? "movie");
|
||||||
|
const sync = () => {
|
||||||
|
const video = this.host.audio.mediaOf(channel);
|
||||||
|
if (video instanceof HTMLVideoElement && video.parentElement !== box) {
|
||||||
|
video.style.width = video.style.height = "100%";
|
||||||
|
box.replaceChildren(video);
|
||||||
|
} else if (!video && box.firstChild) box.replaceChildren();
|
||||||
|
if (box.isConnected || !box.dataset.seen) {
|
||||||
|
if (box.isConnected) box.dataset.seen = "1";
|
||||||
|
window.setTimeout(sync, 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sync();
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
case "alphamask": {
|
||||||
|
const child = this.displayable(a, depth + 1);
|
||||||
|
const mask = this.displayable(b, depth + 1);
|
||||||
|
const url = mask instanceof HTMLImageElement ? mask.src : "";
|
||||||
|
if (child && url) {
|
||||||
|
child.style.maskImage = `url("${url}")`;
|
||||||
|
child.style.maskSize = "100% 100%";
|
||||||
|
child.style.maskRepeat = "no-repeat";
|
||||||
|
}
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
case "trackcursor": return this.trackCursor(this.displayable(a, depth + 1), Number(b) || 1);
|
||||||
case "composite": {
|
case "composite": {
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
const size = a;
|
const size = a;
|
||||||
@@ -745,6 +936,39 @@ export class ScreenManager {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Menu parallax: the picture drifts opposite to the mouse, `damping` times less than the pointer moves. */
|
||||||
|
private trackCursor(child: HTMLElement | null, damping: number): HTMLElement | null {
|
||||||
|
if (!child) return null;
|
||||||
|
const box = document.createElement("div");
|
||||||
|
box.style.position = "absolute";
|
||||||
|
box.style.left = box.style.top = "0";
|
||||||
|
box.append(child);
|
||||||
|
let target = [0, 0];
|
||||||
|
let shown = [0, 0];
|
||||||
|
let last = performance.now();
|
||||||
|
const move = (ev: PointerEvent) => {
|
||||||
|
const area = box.parentElement;
|
||||||
|
if (!area) return;
|
||||||
|
const rect = area.getBoundingClientRect();
|
||||||
|
const scale = area.offsetWidth ? rect.width / area.offsetWidth : 1;
|
||||||
|
target = [-(ev.clientX - rect.left) / scale / damping, -(ev.clientY - rect.top) / scale / damping];
|
||||||
|
};
|
||||||
|
window.addEventListener("pointermove", move);
|
||||||
|
const frame = (now: number) => {
|
||||||
|
if (!box.isConnected && now - last > 1000) {
|
||||||
|
window.removeEventListener("pointermove", move);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const k = Math.min(1, 1.5 * damping * (now - last) / 1000);
|
||||||
|
shown = [shown[0] + (target[0] - shown[0]) * k, shown[1] + (target[1] - shown[1]) * k];
|
||||||
|
box.style.transform = `translate(${Math.floor(shown[0])}px, ${Math.floor(shown[1])}px)`;
|
||||||
|
last = now;
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(frame);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
/** `crop (x, y, w, h)`: show only that part of the picture. */
|
/** `crop (x, y, w, h)`: show only that part of the picture. */
|
||||||
private cropped(el: HTMLElement, rect: any): HTMLElement {
|
private cropped(el: HTMLElement, rect: any): HTMLElement {
|
||||||
if (!Array.isArray(rect) || rect.length !== 4 || rect.some((n) => num(n) === null)) return el;
|
if (!Array.isArray(rect) || rect.length !== 4 || rect.some((n) => num(n) === null)) return el;
|
||||||
@@ -871,6 +1095,7 @@ export class ScreenManager {
|
|||||||
const activate = (ev: Event) => {
|
const activate = (ev: Event) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
if (!sensitive) return;
|
if (!sensitive) return;
|
||||||
|
this.activated = el;
|
||||||
if (typeof clickSound === "string") this.host.play("sound", clickSound, false);
|
if (typeof clickSound === "string") this.host.play("sound", clickSound, false);
|
||||||
this.run(action, ctx.inst);
|
this.run(action, ctx.inst);
|
||||||
};
|
};
|
||||||
@@ -1088,15 +1313,29 @@ export class ScreenManager {
|
|||||||
|
|
||||||
private actionSensitive(action: any): boolean {
|
private actionSensitive(action: any): boolean {
|
||||||
const list = Array.isArray(action) ? action : [action];
|
const list = Array.isArray(action) ? action : [action];
|
||||||
return list.every((a) => !(a instanceof ScreenAction) ||
|
return list.every((a) => {
|
||||||
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true));
|
if (isNativeAction(a)) return a.get_sensitive();
|
||||||
|
if (a instanceof PyInstance) return this.askAction(a, "get_sensitive", true);
|
||||||
|
return !(a instanceof ScreenAction) ||
|
||||||
|
(a.kind === "SensitiveIf" ? truthy(a.args[0]) : a.kind === "Replay" ? !truthy(a.kw.locked ?? a.args[2]) : true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `get_selected` / `get_sensitive` of a game's Action class, when it defines them. */
|
||||||
|
private askAction(action: PyInstance, method: string, fallback: boolean): boolean {
|
||||||
|
const found = action.cls.find(method)?.value;
|
||||||
|
return typeof found === "function" ? truthy(found(action)) : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
private actionSelected(action: any): boolean {
|
private actionSelected(action: any): boolean {
|
||||||
const list = Array.isArray(action) ? action : [action];
|
const list = Array.isArray(action) ? action : [action];
|
||||||
return list.some((a) => {
|
return list.some((a) => {
|
||||||
|
if (isNativeAction(a)) return a.get_selected();
|
||||||
|
if (a instanceof PyInstance) return this.askAction(a, "get_selected", false);
|
||||||
if (!(a instanceof ScreenAction)) return false;
|
if (!(a instanceof ScreenAction)) return false;
|
||||||
switch (a.kind) {
|
switch (a.kind) {
|
||||||
|
case "ToggleSetMembership": return Array.isArray(a.args[0]) && a.args[0].some((x: any) => pyEq(x, a.args[1]));
|
||||||
|
case "ToggleFocus": return this.focus?.id === pyStr(a.args[0]);
|
||||||
case "SelectedIf": return truthy(a.args[0]);
|
case "SelectedIf": return truthy(a.args[0]);
|
||||||
case "SetVariable": return this.valueOf(a.args[0], a.screen) === a.args[1];
|
case "SetVariable": return this.valueOf(a.args[0], a.screen) === a.args[1];
|
||||||
case "SetScreenVariable": return a.screen?.vars.get(a.args[0]) === a.args[1];
|
case "SetScreenVariable": return a.screen?.vars.get(a.args[0]) === a.args[1];
|
||||||
@@ -1127,6 +1366,7 @@ export class ScreenManager {
|
|||||||
|
|
||||||
/** Run what a widget's `action` evaluated to. */
|
/** Run what a widget's `action` evaluated to. */
|
||||||
run(action: any, screen: Instance | null) {
|
run(action: any, screen: Instance | null) {
|
||||||
|
if (this.suspended) return;
|
||||||
// actions act on the game, not on the screen's own variable scope
|
// actions act on the game, not on the screen's own variable scope
|
||||||
const { py } = this.host;
|
const { py } = this.host;
|
||||||
const savedFn = py.fn;
|
const savedFn = py.fn;
|
||||||
@@ -1134,7 +1374,10 @@ export class ScreenManager {
|
|||||||
try {
|
try {
|
||||||
this.perform(action, screen);
|
this.perform(action, screen);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!(err instanceof ActionAbort)) console.warn("[pgvn] действие screen", err);
|
if (!(err instanceof ActionAbort)) {
|
||||||
|
if (this.host.error) this.host.error((err as Error).message);
|
||||||
|
else console.warn("[pgvn] действие screen", err);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
py.fn = savedFn;
|
py.fn = savedFn;
|
||||||
}
|
}
|
||||||
@@ -1147,8 +1390,13 @@ export class ScreenManager {
|
|||||||
for (const item of action) this.perform(item, screen);
|
for (const item of action) this.perform(item, screen);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof action === "function") {
|
if (isNativeAction(action)) {
|
||||||
action();
|
action.__call__();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const call = callableOf(action);
|
||||||
|
if (call) { // a game's own Action class (or any function)
|
||||||
|
call();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(action instanceof ScreenAction)) return;
|
if (!(action instanceof ScreenAction)) return;
|
||||||
@@ -1163,6 +1411,10 @@ export class ScreenManager {
|
|||||||
owner.onReturn(a ?? null);
|
owner.onReturn(a ?? null);
|
||||||
throw new ActionAbort();
|
throw new ActionAbort();
|
||||||
}
|
}
|
||||||
|
if (this.interactionReturn) {
|
||||||
|
this.interactionReturn(a ?? null);
|
||||||
|
throw new ActionAbort();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case "SetVariable": this.assign(a, b, owner); return;
|
case "SetVariable": this.assign(a, b, owner); return;
|
||||||
case "ToggleVariable": {
|
case "ToggleVariable": {
|
||||||
@@ -1203,7 +1455,25 @@ export class ScreenManager {
|
|||||||
if (typeof a === "function") a(...rest, ...(Object.keys(action.kw).length ? [{ __kw: action.kw }] : []));
|
if (typeof a === "function") a(...rest, ...(Object.keys(action.kw).length ? [{ __kw: action.kw }] : []));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case "ToggleSetMembership": {
|
||||||
|
const set = a;
|
||||||
|
if (!Array.isArray(set)) return;
|
||||||
|
const at = set.findIndex((x: any) => pyEq(x, b));
|
||||||
|
if (at >= 0) set.splice(at, 1);
|
||||||
|
else set.push(b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "ToggleFocus":
|
||||||
|
this.focus = this.focus?.id === pyStr(a) ? null : { id: pyStr(a), rect: this.screenRect(this.activated) };
|
||||||
|
return;
|
||||||
|
case "PauseAudio": {
|
||||||
|
const channel = String(a ?? "music");
|
||||||
|
const value = action.args.length > 1 || action.kw.value !== undefined ? (b ?? action.kw.value) : "toggle";
|
||||||
|
this.host.audio.setPaused(channel, value === "toggle" ? !this.host.audio.paused(channel) : truthy(value));
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "Play": this.host.play(String(a), String(b), false); return;
|
case "Play": this.host.play(String(a), String(b), false); return;
|
||||||
|
case "SetMixer": this.host.setMixer(String(a), Number(b)); return;
|
||||||
case "Queue": this.host.play(String(a), String(b), false); return;
|
case "Queue": this.host.play(String(a), String(b), false); return;
|
||||||
case "Stop": this.host.stop(String(a)); return;
|
case "Stop": this.host.stop(String(a)); return;
|
||||||
case "Notify": this.host.notify(pyStr(a)); return;
|
case "Notify": this.host.notify(pyStr(a)); return;
|
||||||
@@ -1239,12 +1509,39 @@ 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", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay"]) out[kind] = make(kind);
|
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
|
||||||
|
"ToggleFocus", "PauseAudio", "SetMixer"]) out[kind] = make(kind);
|
||||||
|
out.GetFocusRect = (id: any) => (this.focus?.id === pyStr(id) ? this.focus.rect : null);
|
||||||
|
out.MixerValue = (mixer: any) => {
|
||||||
|
return new BarValue(() => this.host.audio.mixerVolume(String(mixer)), () => 1, (v) => this.host.setMixer(String(mixer), v));
|
||||||
|
};
|
||||||
|
out.AudioPositionValue = (...a: any[]) => {
|
||||||
|
const kw = a.length && a[a.length - 1]?.__kw ? a[a.length - 1].__kw : {};
|
||||||
|
const channel = String(kw.channel ?? (typeof a[0] === "string" ? a[0] : "music"));
|
||||||
|
const value = new BarValue(() => this.host.audio.position(channel) ?? 0, () => this.host.audio.duration(channel) ?? 1,
|
||||||
|
(v) => this.host.audio.seek(channel, v));
|
||||||
|
value.live = true;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
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");
|
||||||
return new Displayable(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {});
|
return new Displayable(kind, hasKw ? args.slice(0, -1) : args, hasKw ? last.__kw : {});
|
||||||
};
|
};
|
||||||
|
out.Movie = shape("movie");
|
||||||
|
// a bar that scrolls the viewport with this id
|
||||||
|
const scrollValue = (axis: "x" | "y") => (id: any) => {
|
||||||
|
const target = () => document.querySelector<HTMLElement>(`[data-viewport-id="${CSS.escape(pyStr(id))}"]`);
|
||||||
|
const top = axis === "y" ? "scrollTop" : "scrollLeft";
|
||||||
|
const full = (el: HTMLElement) => (axis === "y" ? el.scrollHeight - el.clientHeight : el.scrollWidth - el.clientWidth);
|
||||||
|
const value = new BarValue(() => target()?.[top] ?? 0, () => Math.max(1, (target() ? full(target()!) : 1)),
|
||||||
|
(v) => { const el = target(); if (el) el[top] = v; });
|
||||||
|
value.live = true;
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
out.XScrollValue = scrollValue("x");
|
||||||
|
out.YScrollValue = scrollValue("y");
|
||||||
out.Crop = shape("crop");
|
out.Crop = shape("crop");
|
||||||
out.LiveCrop = shape("crop");
|
out.LiveCrop = shape("crop");
|
||||||
out.Solid = shape("solid");
|
out.Solid = shape("solid");
|
||||||
@@ -1261,8 +1558,17 @@ export class ScreenManager {
|
|||||||
};
|
};
|
||||||
out.Text = shape("text");
|
out.Text = shape("text");
|
||||||
out.Image = shape("image");
|
out.Image = shape("image");
|
||||||
|
out.ImageReference = shape("image");
|
||||||
|
out.Gallery = galleryClass({
|
||||||
|
seen: (name) => truthy(this.host.py.eval({ k: "call", f: { k: "attr", o: { k: "name", id: "renpy" }, a: "seen_image" }, a: [{ k: "const", v: name }], kw: {} })),
|
||||||
|
evaluate: (source) => this.host.py.eval(parseExpression(source)),
|
||||||
|
show: (pictures, transition) => this.showGallery(pictures, transition),
|
||||||
|
button: (image, enabled, action, properties) => new Displayable("gallerybutton", [],
|
||||||
|
{ ...properties, idle: image, insensitive: image, sensitive: enabled, action }),
|
||||||
|
});
|
||||||
out.Fixed = shape("composite");
|
out.Fixed = shape("composite");
|
||||||
out.AlphaMask = (base: any) => base;
|
out.AlphaMask = shape("alphamask");
|
||||||
|
out.TrackCursor = shape("trackcursor");
|
||||||
out.At = (child: any) => child;
|
out.At = (child: any) => child;
|
||||||
Object.assign(out, colorMatrices);
|
Object.assign(out, colorMatrices);
|
||||||
out.im = {
|
out.im = {
|
||||||
@@ -1286,9 +1592,23 @@ export class ScreenManager {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where the imperative `renpy.ui` API puts widgets: the screen code that is running. */
|
||||||
|
private uiTarget: { out: HTMLElement[]; ctx: Ctx } | null = null;
|
||||||
|
|
||||||
/** `renpy.show_screen` and friends. */
|
/** `renpy.show_screen` and friends. */
|
||||||
renpyApi(): Record<string, any> {
|
renpyApi(): Record<string, any> {
|
||||||
|
const widget = (kind: string) => (...a: any[]) => {
|
||||||
|
const last = a[a.length - 1];
|
||||||
|
const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
|
||||||
|
const target = this.uiTarget;
|
||||||
|
if (!target) throw new Error(`renpy.ui.${kind} вызван вне screen`);
|
||||||
|
const props = Object.fromEntries(Object.entries(kw).map(([k, v]) => [k, { k: "const", v }]));
|
||||||
|
const built = this.widget({ w: kind, args: [], props }, target.ctx);
|
||||||
|
if (built) target.out.push(built);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
return {
|
return {
|
||||||
|
ui: { imagebutton: widget("imagebutton") },
|
||||||
show_screen: (...a: any[]) => {
|
show_screen: (...a: any[]) => {
|
||||||
const last = a[a.length - 1];
|
const last = a[a.length - 1];
|
||||||
const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
|
const kw = last && typeof last === "object" && Object.hasOwn(last, "__kw") ? last.__kw : {};
|
||||||
|
|||||||
@@ -19,9 +19,13 @@ export interface ImageDef {
|
|||||||
color?: string;
|
color?: string;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
movie?: string;
|
movie?: string;
|
||||||
|
movieMask?: string;
|
||||||
|
movieLoop?: boolean;
|
||||||
expr?: Expr;
|
expr?: Expr;
|
||||||
switch?: [Expr, ImageDef][];
|
switch?: [Expr, ImageDef][];
|
||||||
composite?: { pos: [number, number]; img: ImageDef }[];
|
composite?: { pos: [number, number]; img: ImageDef }[];
|
||||||
|
/** `AlphaMask(img, mask)`: the picture, visible where the mask is. */
|
||||||
|
alphaMask?: { img: ImageDef; mask: ImageDef };
|
||||||
size?: [number, number] | null;
|
size?: [number, number] | null;
|
||||||
text?: string;
|
text?: string;
|
||||||
textExpr?: Expr;
|
textExpr?: Expr;
|
||||||
@@ -100,6 +104,7 @@ export interface ScriptModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GameData {
|
export interface GameData {
|
||||||
|
build?: Record<string, any>;
|
||||||
/** Natural image dimensions are divided by this factor after an asset replacement. */
|
/** Natural image dimensions are divided by this factor after an asset replacement. */
|
||||||
assetScale?: Record<string, number>;
|
assetScale?: Record<string, number>;
|
||||||
runtime?: string;
|
runtime?: string;
|
||||||
@@ -155,6 +160,7 @@ export interface ShownImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SaveData {
|
export interface SaveData {
|
||||||
|
registeredImages?: Record<string, ImageDef>;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
v: 1;
|
v: 1;
|
||||||
cur: Frame;
|
cur: Frame;
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ function tick(ms) {
|
|||||||
const volumes = () => ({ music: 0.7, sound: 0.8, voice: 1 });
|
const volumes = () => ({ music: 0.7, sound: 0.8, voice: 1 });
|
||||||
const system = (registered = {}) => new AudioSystem(volumes(), (file) => file, registered);
|
const system = (registered = {}) => new AudioSystem(volumes(), (file) => file, registered);
|
||||||
|
|
||||||
|
test("custom mixers control only their channels and survive changes to shared volumes", () => {
|
||||||
|
const audio = system({ ambient: { mixer: "ambiant", loop: true }, ambient2: { mixer: "ambiant", loop: true } });
|
||||||
|
audio.play("ambient", ["one"], {});
|
||||||
|
audio.play("sound", ["effect"], {});
|
||||||
|
audio.setMixer("ambiant", 0.3);
|
||||||
|
assert.deepEqual(elements.map((el) => el.volume), [0.3, 0.8]);
|
||||||
|
audio.setVolumes({ music: 0, sound: 0.2, voice: 1 });
|
||||||
|
audio.play("ambient2", ["two"], {});
|
||||||
|
assert.deepEqual(elements.map((el) => el.volume), [0.3, 0.2, 0.3]);
|
||||||
|
assert.equal(audio.mixerVolume("ambiant"), 0.3);
|
||||||
|
});
|
||||||
|
|
||||||
test("volume changes apply immediately to each mixer and future tracks", () => {
|
test("volume changes apply immediately to each mixer and future tracks", () => {
|
||||||
const audio = system({ ambience: { mixer: "sfx", loop: true }, score: { mixer: "music", loop: true } });
|
const audio = system({ ambience: { mixer: "sfx", loop: true }, score: { mixer: "music", loop: true } });
|
||||||
for (const ch of ["music", "sound", "voice", "ambience", "score"]) audio.play(ch, [ch], {});
|
for (const ch of ["music", "sound", "voice", "ambience", "score"]) audio.play(ch, [ch], {});
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("../src/player/cutscene.ts", import.meta.url), "utf8");
|
||||||
|
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
|
||||||
|
const { cutscene } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
|
||||||
|
|
||||||
|
function setup(options = {}, blocked = false) {
|
||||||
|
const { document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||||
|
globalThis.document = document;
|
||||||
|
const create = document.createElement.bind(document);
|
||||||
|
let starts = 0;
|
||||||
|
document.createElement = (name) => {
|
||||||
|
const element = create(name);
|
||||||
|
if (name === "video") {
|
||||||
|
element.play = () => { starts++; return blocked ? Promise.reject(new Error("gesture")) : Promise.resolve(); };
|
||||||
|
element.pause = () => {};
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const movie = cutscene(document.body, "/film.webm", { delay: null, loops: 0, volume: 0.5, ...options });
|
||||||
|
return { ...movie, document, video: document.querySelector("video"), starts: () => starts };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a cutscene waits for its final loop and returns false on natural completion", async () => {
|
||||||
|
const movie = setup({ loops: 1 });
|
||||||
|
movie.video.onended();
|
||||||
|
assert.equal(movie.starts(), 2);
|
||||||
|
movie.video.onended();
|
||||||
|
assert.equal(await movie.finished, false);
|
||||||
|
movie.destroy();
|
||||||
|
assert.equal(movie.document.querySelector("video"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clicking skips a cutscene and delay -1 holds its last frame", async () => {
|
||||||
|
const movie = setup({ delay: -1 });
|
||||||
|
movie.video.onended();
|
||||||
|
let resolved = false;
|
||||||
|
movie.finished.then(() => { resolved = true; });
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(resolved, false);
|
||||||
|
movie.document.querySelector(".pg-cutscene").onclick({ stopPropagation() {} });
|
||||||
|
assert.equal(await movie.finished, true);
|
||||||
|
movie.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a timed cutscene completes after playback starts", async () => {
|
||||||
|
const movie = setup({ delay: 0 });
|
||||||
|
movie.video.onplaying();
|
||||||
|
assert.equal(await movie.finished, false);
|
||||||
|
movie.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("blocked autoplay offers a play button, and media errors fail explicitly", async () => {
|
||||||
|
const movie = setup({}, true);
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(movie.document.querySelector("button").hidden, false);
|
||||||
|
const failed = assert.rejects(movie.finished, /Не удалось воспроизвести/);
|
||||||
|
movie.video.onerror();
|
||||||
|
await failed;
|
||||||
|
movie.destroy();
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { makeEngine } from "./helpers/renpy-engine.mjs";
|
||||||
|
|
||||||
|
const runtime = () => ({ game: { resolution: [640, 480], entry: "start", ui: {}, files: {},
|
||||||
|
defaults: [], init: [], characters: {}, channels: {}, images: { background: { file: "background.webp" } },
|
||||||
|
transforms: {}, atl: {}, transitions: {}, screens: {}, styles: {}, audio: {} },
|
||||||
|
modules: [{ labels: [{ name: "start", body: [{ op: "return" }] }], anchors: {} }] });
|
||||||
|
|
||||||
|
test("runtime image declarations produce scaled thumbnails and survive saving", () => {
|
||||||
|
const { engine } = makeEngine(runtime());
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
engine.fileUrl = (file) => `/mock/${file}`;
|
||||||
|
const image = engine.builtins.im.Scale(engine.builtins.ImageReference("background"), 384, 216);
|
||||||
|
engine.builtins.renpy.image("thumbnail", image);
|
||||||
|
const element = engine.screenImage("thumbnail");
|
||||||
|
assert.equal(element.style.width, "384px");
|
||||||
|
assert.equal(element.style.height, "216px");
|
||||||
|
assert.equal(element.src, "/mock/background.webp");
|
||||||
|
const saved = structuredClone(engine.snapshotState());
|
||||||
|
engine.newStore();
|
||||||
|
assert.equal(engine.resolveImage(["thumbnail"]), null);
|
||||||
|
engine.applySave(saved);
|
||||||
|
assert.deepEqual(engine.resolveImage(["thumbnail"]).def.props.size, [384, 216]);
|
||||||
|
const before = Object.getPrototypeOf(engine.game.images);
|
||||||
|
engine.builtins.renpy.image("__proto__", "safe.webp");
|
||||||
|
assert.equal(Object.getPrototypeOf(engine.game.images), before);
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a missing original label stops execution instead of advancing into another branch", () => {
|
||||||
|
const { engine } = makeEngine(runtime());
|
||||||
|
try {
|
||||||
|
engine.cur = { label: "start", path: [0] };
|
||||||
|
assert.throws(() => engine.jump("missing_original_label"), /не найдена/);
|
||||||
|
assert.deepEqual(engine.cur, { label: "start", path: [0] });
|
||||||
|
engine.screens.run(engine.builtins.Jump("missing_original_label"), null);
|
||||||
|
assert.match(engine.notifyEl.textContent, /missing_original_label/);
|
||||||
|
assert.equal(engine.running, false);
|
||||||
|
engine.newStore();
|
||||||
|
assert.equal(engine.screens.layer.style.visibility, "");
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native Gallery instances keep their methods and registration target after a save", () => {
|
||||||
|
const { engine } = makeEngine(runtime());
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
const cls = engine.builtins.Gallery;
|
||||||
|
const gallery = engine.py.instantiate(cls, []);
|
||||||
|
cls.find("button").value(gallery, "art");
|
||||||
|
cls.find("image").value(gallery, "background");
|
||||||
|
engine.store.gallery = gallery;
|
||||||
|
const saved = structuredClone(engine.snapshotState());
|
||||||
|
engine.applySave(saved);
|
||||||
|
const restored = engine.store.gallery;
|
||||||
|
assert.equal(typeof engine.py.attrOf(restored, "make_button"), "function");
|
||||||
|
cls.find("condition").value(restored, "False");
|
||||||
|
assert.equal(cls.find("Action").value(restored, "art"), null);
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an animated preview discarded before its first timer does not keep rendering", async () => {
|
||||||
|
const { engine } = makeEngine(runtime());
|
||||||
|
let rendered = 0;
|
||||||
|
try {
|
||||||
|
engine.fileUrl = (file) => { rendered++; return `/mock/${file}`; };
|
||||||
|
engine.renderer.displayable({ repeat: true, frames: [
|
||||||
|
{ img: { file: "a.webp" }, d: 0.01 }, { img: { file: "b.webp" }, d: 0.01 },
|
||||||
|
] });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||||
|
assert.equal(rendered, 1);
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
|
||||||
|
const bundle = await build({ entryPoints: [fileURLToPath(new URL("../../src/player/engine.ts", import.meta.url))],
|
||||||
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
|
||||||
|
const { Engine } = await import(`data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString("base64")}`);
|
||||||
|
Error.stackTraceLimit = 2; // data-URL bundle frames otherwise print the entire bundle on a failure.
|
||||||
|
|
||||||
|
export function makeEngine(runtime) {
|
||||||
|
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||||
|
const storage = new Map();
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window, document, HTMLElement: window.HTMLElement, HTMLImageElement: window.HTMLImageElement,
|
||||||
|
localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), removeItem: (key) => storage.delete(key) },
|
||||||
|
ResizeObserver: class { observe() {} disconnect() {} },
|
||||||
|
requestAnimationFrame: (fn) => setTimeout(() => fn(performance.now()), 2), cancelAnimationFrame: clearTimeout,
|
||||||
|
});
|
||||||
|
const tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
const engine = new Engine(root, structuredClone(runtime.game), { files: {} }, structuredClone(runtime.modules),
|
||||||
|
{ list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
|
||||||
|
"runtime-test", () => {}, tr, async () => tr);
|
||||||
|
return { engine, root, window, document };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const until = async (condition) => {
|
||||||
|
const deadline = performance.now() + 3000;
|
||||||
|
while (!condition()) {
|
||||||
|
if (performance.now() > deadline) throw new Error("Ожидаемое состояние плеера не наступило");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { makeEngine, until } from "./helpers/renpy-engine.mjs";
|
||||||
|
|
||||||
|
const original = process.env.PGAMES_MIST_RUNTIME;
|
||||||
|
const runtime = original ? JSON.parse(readFileSync(original, "utf8")) : null;
|
||||||
|
const click = (element) => element.onclick({ stopPropagation() {}, preventDefault() {} });
|
||||||
|
const enter = (engine, label) => engine.restart(() => engine.guardedJump(label));
|
||||||
|
|
||||||
|
test("MIST's actual raise-hand loop accepts screen input and resumes after a save", { skip: !runtime }, async () => {
|
||||||
|
const { engine, root } = makeEngine(runtime);
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
engine.store.keysqte = false;
|
||||||
|
engine.store.last_pressed = "";
|
||||||
|
engine.store.player_name = "Player";
|
||||||
|
enter(engine, "raisehand_label");
|
||||||
|
await until(() => engine.screens.interactionReturn && root.querySelector(".pg-simgbtn"));
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
click(root.querySelector(".pg-simgbtn"));
|
||||||
|
await until(() => engine.screens.interactionReturn);
|
||||||
|
}
|
||||||
|
const position = engine.store.object_pos;
|
||||||
|
assert.ok(position < 600 && position > 50);
|
||||||
|
const save = structuredClone(engine.snapshotState());
|
||||||
|
engine.restart(() => engine.applySave(save));
|
||||||
|
await until(() => engine.screens.interactionReturn);
|
||||||
|
assert.equal(engine.store.object_pos, position);
|
||||||
|
for (let i = 0; i < 30 && engine.store.object_pos > 50; i++) {
|
||||||
|
click(root.querySelector(".pg-simgbtn"));
|
||||||
|
await until(() => engine.screens.interactionReturn || engine.store.object_pos === 50);
|
||||||
|
}
|
||||||
|
assert.equal(engine.store.object_pos, 50);
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MIST's actual QTE timer reaches the loss branch", { skip: !runtime }, async () => {
|
||||||
|
const { engine } = makeEngine(runtime);
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
Object.assign(engine.store, { timer_on: true, keysqte: false, object_pos: 301, lose_pos: 300, finish_pos: 1600, last_pressed: "" });
|
||||||
|
engine.screens.show("game_scr");
|
||||||
|
enter(engine, "game_loop");
|
||||||
|
await until(() => engine.store.timer_on === false);
|
||||||
|
assert.ok(engine.store.object_pos <= 300);
|
||||||
|
assert.equal(engine.cur.label, "qte_lose");
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MIST's actual QTE accepts alternating keys and reaches the win branch", { skip: !runtime }, async () => {
|
||||||
|
const { engine, root } = makeEngine(runtime);
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
Object.assign(engine.store, { timer_on: true, keysqte: false, object_pos: 1590, lose_pos: 300, finish_pos: 1600, last_pressed: "q" });
|
||||||
|
engine.screens.show("game_scr");
|
||||||
|
enter(engine, "game_loop");
|
||||||
|
await until(() => engine.screens.interactionReturn && root.querySelectorAll(".pg-simgbtn").length === 2);
|
||||||
|
click(root.querySelectorAll(".pg-simgbtn")[1]);
|
||||||
|
await until(() => engine.store.timer_on === false);
|
||||||
|
assert.ok(engine.store.object_pos >= 1600);
|
||||||
|
assert.equal(engine.cur.label, "qte_win");
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import ts from "typescript";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
|
||||||
|
const source = readFileSync(new URL("../src/player/movie.ts", import.meta.url), "utf8");
|
||||||
|
const { outputText } = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext } });
|
||||||
|
const { applyMovieMask, movieDisplayable } = await import(`data:text/javascript;base64,${Buffer.from(outputText).toString("base64")}`);
|
||||||
|
|
||||||
|
test("video opacity uses the mask's red channel and retains existing transparency", () => {
|
||||||
|
const pixels = new Uint8ClampedArray([200, 10, 20, 255, 30, 40, 50, 128]);
|
||||||
|
applyMovieMask(pixels, new Uint8ClampedArray([0, 255, 255, 255, 128, 0, 0, 255]));
|
||||||
|
assert.deepEqual([...pixels], [200, 10, 20, 0, 30, 40, 50, 64]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("masked movie composites frames, synchronizes the mask and stops when removed", () => {
|
||||||
|
const { document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||||
|
globalThis.document = document;
|
||||||
|
const create = document.createElement.bind(document);
|
||||||
|
const videos = [], frames = [], painted = [];
|
||||||
|
let canvases = 0;
|
||||||
|
globalThis.requestAnimationFrame = (callback) => frames.push(callback);
|
||||||
|
document.createElement = (name) => {
|
||||||
|
const element = create(name);
|
||||||
|
if (name === "video") {
|
||||||
|
Object.assign(element, { readyState: 2, videoWidth: 2, videoHeight: 1, currentTime: 0, ended: false,
|
||||||
|
play: () => Promise.resolve(), pause: () => { element.paused = true; } });
|
||||||
|
videos.push(element);
|
||||||
|
}
|
||||||
|
if (name === "canvas") {
|
||||||
|
const mask = canvases++ > 0;
|
||||||
|
element.getContext = () => ({ clearRect() {}, drawImage() {},
|
||||||
|
getImageData: () => ({ data: new Uint8ClampedArray(mask
|
||||||
|
? [0, 0, 0, 255, 128, 128, 128, 255] : [200, 10, 20, 255, 30, 40, 50, 255]) }),
|
||||||
|
putImageData: (image) => painted.push([...image.data]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
};
|
||||||
|
const canvas = movieDisplayable("/movie.webm", "/mask.webm", false);
|
||||||
|
document.body.append(canvas);
|
||||||
|
videos[1].currentTime = 2;
|
||||||
|
frames.shift()();
|
||||||
|
assert.deepEqual(painted[0], [200, 10, 20, 0, 30, 40, 50, 128]);
|
||||||
|
assert.equal(videos[1].currentTime, 0);
|
||||||
|
canvas.remove();
|
||||||
|
frames.shift()();
|
||||||
|
assert.ok(videos.every((video) => video.paused));
|
||||||
|
assert.equal(frames.length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Optional smoke test for locally exported original game scripts; no game assets in git.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { parseHTML } from "linkedom";
|
||||||
|
|
||||||
|
test("original Ren'Py scripts initialize and retain the player's save loader", {
|
||||||
|
skip: !process.env.PGAMES_RENPY_RUNTIME,
|
||||||
|
}, async () => {
|
||||||
|
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
|
||||||
|
const storage = new Map();
|
||||||
|
Object.assign(globalThis, {
|
||||||
|
window, document,
|
||||||
|
localStorage: { getItem: (key) => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) },
|
||||||
|
ResizeObserver: class { observe() {} disconnect() {} },
|
||||||
|
requestAnimationFrame: (fn) => setTimeout(() => fn(performance.now()), 4),
|
||||||
|
cancelAnimationFrame: clearTimeout,
|
||||||
|
});
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [fileURLToPath(new URL("../src/player/engine.ts", import.meta.url))],
|
||||||
|
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
|
||||||
|
});
|
||||||
|
const { Engine } = await import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}`);
|
||||||
|
const { game, modules } = JSON.parse(readFileSync(process.env.PGAMES_RENPY_RUNTIME, "utf8"));
|
||||||
|
const tr = { lang: "en", renpyName: null, str: (s) => s, line: (_id, s) => s };
|
||||||
|
const root = document.createElement("div");
|
||||||
|
document.body.append(root);
|
||||||
|
const faults = [];
|
||||||
|
const warn = console.warn;
|
||||||
|
console.warn = (...args) => { faults.push(args.map(String).join(" ")); };
|
||||||
|
let engine;
|
||||||
|
try {
|
||||||
|
engine = new Engine(root, game, { files: {} }, modules,
|
||||||
|
{ list: async () => [], get: async () => null, put: async () => {}, remove: async () => {} },
|
||||||
|
"original-smoke", () => {}, tr, async () => tr);
|
||||||
|
const load = engine.builtins.renpy.load;
|
||||||
|
const room = engine.py.instantiate(engine.builtins.MusicRoom, []);
|
||||||
|
const stop = engine.builtins.MusicRoom.find("Stop").value(room);
|
||||||
|
assert.equal(stop.get_selected(), true, "MusicRoom must capture the initialized audio system");
|
||||||
|
for (let i = 0; i < game.init.length; i++) {
|
||||||
|
try { engine.py.exec([game.init[i]]); }
|
||||||
|
catch (error) { throw new Error(`init[${i}] ${JSON.stringify(game.init[i]).slice(0, 350)}: ${error.message}`); }
|
||||||
|
}
|
||||||
|
engine.newStore();
|
||||||
|
assert.equal(engine.builtins.renpy.load, load, "init must retain the native save loader");
|
||||||
|
assert.deepEqual(faults.filter((s) => /ошибка init|ошибка выражения|неизвестное имя|неизвестная функция/.test(s)), []);
|
||||||
|
} finally {
|
||||||
|
console.warn = warn;
|
||||||
|
engine?.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -32,6 +32,22 @@ function python(extra = {}) {
|
|||||||
return { py, store };
|
return { py, store };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("interpreted instances can own arguments without exposing host function properties", () => {
|
||||||
|
const { py, store } = python();
|
||||||
|
Object.assign(store, m.userBuiltins(py));
|
||||||
|
store.entry = new m.PyInstance(new m.PyClass("Entry", [m.OBJECT]));
|
||||||
|
py.exec(parseStatements('entry.arguments = {"x": 1}'));
|
||||||
|
assert.equal(py.eval(parseExpression('entry.arguments["x"]')), 1);
|
||||||
|
assert.equal(py.eval(parseExpression('getattr(entry, "constructor", None)')), null);
|
||||||
|
py.exec(parseStatements('setattr(entry, "arguments", 2)'));
|
||||||
|
assert.equal(py.eval(parseExpression('entry.arguments')), 2);
|
||||||
|
store.host = () => 1;
|
||||||
|
assert.equal(py.eval(parseExpression('host.constructor')), null);
|
||||||
|
assert.equal(py.eval(parseExpression('host.arguments')), null);
|
||||||
|
assert.throws(() => py.exec(parseStatements('entry.__proto__ = 1')), /Служебный/);
|
||||||
|
assert.throws(() => py.exec(parseStatements('entry.constructor = 1')), /Служебный/);
|
||||||
|
});
|
||||||
|
|
||||||
test("colour matrices compose like Ren'Py's: the right one applies first", () => {
|
test("colour matrices compose like Ren'Py's: the right one applies first", () => {
|
||||||
const brighter = colorMatrices.BrightnessMatrix(0.2);
|
const brighter = colorMatrices.BrightnessMatrix(0.2);
|
||||||
const half = colorMatrices.TintMatrix("#808080");
|
const half = colorMatrices.TintMatrix("#808080");
|
||||||
|
|||||||
@@ -69,6 +69,52 @@ test("a screen renders its widgets, conditions, loops and interpolated text", ()
|
|||||||
assert.equal(root.querySelector(".pg-sbtn").style.background, "#123", "style hud_button applies");
|
assert.equal(root.querySelector(".pg-sbtn").style.background, "#123", "style hud_button applies");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("ui.interact receives repeated Returns without hiding an existing screen", () => {
|
||||||
|
const { manager, py } = setup();
|
||||||
|
manager.show("hud");
|
||||||
|
const before = manager.snapshot();
|
||||||
|
const values = [];
|
||||||
|
const builtins = manager.builtins();
|
||||||
|
const cancel = manager.interact((value) => values.push(value));
|
||||||
|
manager.run(builtins.Return("tick"), null);
|
||||||
|
cancel();
|
||||||
|
assert.deepEqual(values, ["tick"]);
|
||||||
|
assert.deepEqual(manager.snapshot(), before);
|
||||||
|
manager.interact((value) => values.push(value));
|
||||||
|
manager.run(builtins.Return(0), null);
|
||||||
|
assert.deepEqual(values, ["tick", 0]);
|
||||||
|
const cancelled = manager.interact((value) => values.push(value));
|
||||||
|
cancelled();
|
||||||
|
manager.run(builtins.Return("stale"), null);
|
||||||
|
assert.deepEqual(values, ["tick", 0]);
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gallery buttons honor unlock conditions and close back to the same screen", () => {
|
||||||
|
const { manager, py } = setup();
|
||||||
|
const builtins = manager.builtins();
|
||||||
|
const gallery = py.instantiate(builtins.Gallery, []);
|
||||||
|
const invoke = (name, ...args) => builtins.Gallery.find(name).value(gallery, ...args);
|
||||||
|
invoke("button", "art");
|
||||||
|
invoke("image", "art.png");
|
||||||
|
invoke("condition", "money > 10");
|
||||||
|
const value = invoke("make_button", "art", "thumb.png", "locked.png");
|
||||||
|
assert.equal(value.kw.sensitive, true);
|
||||||
|
manager.show("hud");
|
||||||
|
manager.run(value.kw.action, null);
|
||||||
|
assert.ok(manager.layer.querySelector(".pg-gallery img[src='/f/art.png']"));
|
||||||
|
const close = [...manager.layer.querySelectorAll(".pg-gallery button")].find((b) => b.textContent === "Закрыть");
|
||||||
|
click(close);
|
||||||
|
assert.equal(manager.layer.querySelector(".pg-gallery"), null);
|
||||||
|
assert.equal(manager.snapshot()[0].name, "hud");
|
||||||
|
py.scope.set("money", 0);
|
||||||
|
assert.equal(invoke("Action", "art"), null);
|
||||||
|
const locked = invoke("make_button", "art", "thumb.png", "locked.png");
|
||||||
|
assert.equal(locked.kw.sensitive, false);
|
||||||
|
assert.equal(locked.kw.idle, "locked.png");
|
||||||
|
manager.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
test("conditions follow the game state when the screen refreshes", async () => {
|
test("conditions follow the game state when the screen refreshes", async () => {
|
||||||
const { manager, store } = setup();
|
const { manager, store } = setup();
|
||||||
manager.show("hud");
|
manager.show("hud");
|
||||||
|
|||||||
Reference in new issue
Block a user