feat: report ATL problems only for used transforms; animate moving images
- Unsupported ATL constructs in a transform now block the import only when the transform is used (at clause, another transform, even before its definition). Stock screens.rpy transforms for unused screens no longer fail games - image blocks with one picture plus linear/ease/parallel/repeat keep their animation (image-level ATL); the renderer also starts it - Python: skip @gui.variant/@renpy.variant functions (inactive variants), treat @renpy.pure as transparent, read renpy.store.x as x - Accept renpy.transition, end_replay, block_rollback and music set_pause/set_pan as presentation-only no-ops, and implement them in the player (set_pause and set_pan were allowed by the converter but missing at runtime) - Skip the 0x52-URM add-on directory (in-game console / save editor) when parsing scripts and media - Tests for lazy ATL warnings, moving images and variants Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ff82d882c0
commit
4418da47eb
9 files changed
+133
-12
No files matched your search
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.converter import pyexpr
|
||||
from app.converter.atl import AtlParser, initial_props, static_props
|
||||
from app.converter.atl import WARPER, AtlParser, initial_props, static_props
|
||||
from app.converter.pyexpr import Expr, Stmt, Unsupported, const
|
||||
from app.converter.rpy_helpers import (
|
||||
_CALL_LINE,
|
||||
@@ -69,6 +69,11 @@ class ScriptData:
|
||||
images: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
transforms: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
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
|
||||
# screens.rpy defines several that no game ever shows).
|
||||
atl_pending: dict[str, list[tuple[str, str]]] = field(default_factory=dict)
|
||||
atl_uses: dict[str, set[str]] = field(default_factory=dict)
|
||||
atl_referenced: set[str] = field(default_factory=set)
|
||||
transitions: dict[str, dict[str, Any] | None] = field(default_factory=dict)
|
||||
audio: dict[str, str] = field(default_factory=dict)
|
||||
defaults: list[list[Any]] = field(default_factory=list)
|
||||
@@ -589,7 +594,34 @@ class RpyParser:
|
||||
self.d.warn(f"Изображение {fname}() не поддерживается", self.where(line))
|
||||
return None
|
||||
|
||||
MOTION_WORDS = {"linear", "ease", "easein", "easeout", "easeinout", "parallel", "choice", "repeat",
|
||||
"block", "on"}
|
||||
|
||||
def displayable_line(self, child: Line) -> bool:
|
||||
if self.cursor(child).string() is not None:
|
||||
return True
|
||||
m = _CALL_LINE.match(child.text)
|
||||
return bool(m and m.group(1) in _DISPLAYABLE_CALLS)
|
||||
|
||||
def atl_image(self, line: Line) -> dict[str, Any] | None:
|
||||
shown = [child for child in line.block if self.displayable_line(child)]
|
||||
rest = [child for child in line.block if not self.displayable_line(child)]
|
||||
if len(shown) == 1 and any(
|
||||
(word := Cursor(child.text).peek_word()) in self.MOTION_WORDS or WARPER.match(word or "")
|
||||
for child in rest):
|
||||
# one picture that moves, e.g. a spinning "click to continue" mark
|
||||
first = self.cursor(shown[0]).string()
|
||||
if first is not None:
|
||||
img = self.displayable_expr(const(first), shown[0])
|
||||
else:
|
||||
expr = self.expr(shown[0].text.split(" with ")[0], shown[0])
|
||||
img = self.displayable_expr(expr, shown[0]) if expr else None
|
||||
if img:
|
||||
props, animation = self.atl_spec(rest)
|
||||
spec: dict[str, Any] = {"props": props}
|
||||
if animation:
|
||||
spec["atl"] = animation
|
||||
return {**img, "at": [*img.get("at", []), spec]}
|
||||
frames: list[dict[str, Any]] = []
|
||||
props: dict[str, Any] = {}
|
||||
repeat = self.atl_frames(line.block, frames, props)
|
||||
@@ -779,17 +811,43 @@ class RpyParser:
|
||||
c.keyword("transform")
|
||||
name = c.word()
|
||||
if name:
|
||||
props, animation = self.atl_spec(line.block)
|
||||
issues: list[tuple[str, str]] = []
|
||||
uses: set[str] = set()
|
||||
props, animation = self.atl_spec(line.block, issues.append, uses)
|
||||
self.d.transforms[name] = props
|
||||
if animation:
|
||||
self.d.atl[name] = animation
|
||||
self.d.atl_pending[name] = issues
|
||||
self.d.atl_uses[name] = uses
|
||||
if name in self.d.atl_referenced:
|
||||
self.use_transform(name)
|
||||
return []
|
||||
|
||||
def atl_spec(self, lines: list[Line]) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
def use_transform(self, name: str) -> None:
|
||||
"""A transform is used by the game: its unsupported constructs now block the import."""
|
||||
self.d.atl_referenced.add(name)
|
||||
pending = self.d.atl_pending.pop(name, [])
|
||||
for message, where in pending:
|
||||
self.d.warn(message, where)
|
||||
for other in self.d.atl_uses.get(name, ()):
|
||||
self.use_transform(other)
|
||||
|
||||
def atl_spec(self, lines: list[Line], collect=None, uses=None) -> tuple[dict[str, Any], dict[str, Any] | None]:
|
||||
"""Parse an ATL block: (properties to start with, timeline or None when it is static)."""
|
||||
parser = AtlParser(lambda message, line: self.d.warn(message, self.where(line)),
|
||||
lambda name: self.d.transforms.get(name) if name not in self.d.atl
|
||||
and name in self.d.transforms else None)
|
||||
def warn(message: str, line: Line) -> None:
|
||||
if collect is None:
|
||||
self.d.warn(message, self.where(line))
|
||||
else:
|
||||
collect((message, self.where(line)))
|
||||
|
||||
def on_use(name: str) -> None:
|
||||
if uses is None:
|
||||
self.use_transform(name)
|
||||
else:
|
||||
uses.add(name)
|
||||
|
||||
parser = AtlParser(warn, lambda name: self.d.transforms.get(name) if name not in self.d.atl
|
||||
and name in self.d.transforms else None, on_use)
|
||||
block = parser.block(lines)
|
||||
static = static_props(block)
|
||||
if static is not None:
|
||||
@@ -854,6 +912,8 @@ class RpyParser:
|
||||
source = source.strip()
|
||||
if source in POSITIONS:
|
||||
return {"pos": source}
|
||||
if source.isidentifier():
|
||||
self.use_transform(source)
|
||||
if source in self.d.transforms:
|
||||
spec = {"props": self.d.transforms[source], "name": source}
|
||||
if source in self.d.atl:
|
||||
@@ -873,10 +933,13 @@ class RpyParser:
|
||||
return {"effect": "shake", "d": args[1] if len(args) > 1 else 0.5,
|
||||
"dist": _const(e["kw"].get("dist")) or 20}
|
||||
if fname in self.d.transforms:
|
||||
self.use_transform(fname)
|
||||
spec = {"props": self.d.transforms[fname], "name": fname}
|
||||
if fname in self.d.atl:
|
||||
spec["atl"] = fname
|
||||
return spec
|
||||
if e and e["k"] == "name":
|
||||
self.use_transform(e["id"])
|
||||
if e and e["k"] == "name" and e["id"] not in self.d.transforms:
|
||||
# transform defined later in another file; the player resolves it by name
|
||||
return {"name": e["id"]}
|
||||
|
||||
Reference in new issue
Block a user