From 5c1e1aee0132a277c3c538d00526663035433b04 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 04:02:22 +0300 Subject: [PATCH] feat: ATL durations in brackets, offset and GPU hints; screen for-index, glued strings and onlayer ATL: `ease(1.0) zoom 1.3`, `ease (0.1)xpos 0.5`, `ease(0.1)rotate 5` and `pause(1.5)` take the duration in brackets; `offset (x, y)` becomes xoffset/yoffset; lines starting with shader, mesh, u_*, blend or transform_anchor are skipped as GPU hints. thelewdesthouse drops from about 1400 to 111 rejections. Screens: `for x index k in seq`, `add"x"` without a space, and `onlayer name` after show/call screen (the layer is ignored, every screen lives in one layer). Transforms named with a capital letter and declared in another file are resolved by the prescan, and `__("Name") "text"` is a translatable speaker. Co-Authored-By: Claude Sonnet 5.5 --- backend/app/converter/atl.py | 33 ++++++++++++++++++++--- backend/app/converter/rpy_parser.py | 10 ++++++- backend/app/converter/screens.py | 5 +++- backend/tests/test_screens.py | 41 +++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 5 deletions(-) diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py index a82a6cb..729aadc 100644 --- a/backend/app/converter/atl.py +++ b/backend/app/converter/atl.py @@ -38,11 +38,12 @@ GLUED = re.compile(r"^([a-z_][a-z_0-9]*)(\(.*\))$", re.S) # Warpers differ only in easing shape; the player implements these four curves and # maps the named variants (easein_quad, ease_back, ...) onto the closest one. WARPER = re.compile(r"^(linear|ease|easein|easeout|easeinout)(_(quad|cubic|quart|quint|expo|circ|back|bounce|elastic|sine))?$") +WARPER_CALL = re.compile(r"^(pause|(?:linear|ease|easein|easeout|easeinout)(?:_(?:quad|cubic|quart|quint|expo|circ|back|bounce|elastic|sine))?)\(") LEADING_ZERO = re.compile(r"(?= len(parts): @@ -219,11 +220,27 @@ class AtlParser: if key == "xycenter" and isinstance(value, (list, tuple)) and len(value) == 2: out["xcenter"], out["ycenter"] = value continue + if key == "offset": # `offset (x, y)` is xoffset and yoffset together + if isinstance(value, (list, tuple)) and len(value) == 2: + out["xoffset"], out["yoffset"] = value + else: + self.warn("Не поддерживается ATL: offset не пара чисел", line) + continue out[key] = list(value) if isinstance(value, tuple) else value return out # --- block ---------------------------------------------------------------------- + @staticmethod + def split_bracket(token: str) -> list[str]: + """`(0.1)xpos` -> `(0.1)`, `xpos`: a bracketed duration with the property glued to it.""" + depth = 0 + for at, ch in enumerate(token): + depth += (ch == "(") - (ch == ")") + if depth == 0: + return [token[:at + 1], token[at + 1:]] if token[at + 1:] else [token] + return [token] + def expand(self, lines: list[Line]) -> list[Line]: """Lines holding several comma-separated statements become one line per statement.""" out: list[Line] = [] @@ -249,6 +266,16 @@ class AtlParser: if split := SPLIT_SIGN.match(parts[0]): # decompiled `xzoom-1` if split[1] in POSITION_PROPS: parts = [split[1], split[2], *parts[1:]] + if called := WARPER_CALL.match(parts[0]): # `easein(1.0) zoom 1.3`: the duration in brackets + depth = 0 + for at, ch in enumerate(parts[0][called.end() - 1:], called.end() - 1): + depth += (ch == "(") - (ch == ")") + if depth == 0: + break + glued = [parts[0][at + 1:]] if parts[0][at + 1:] else [] # `ease(0.1)rotate 5` + parts = [called[1], parts[0][called.end():at], *glued, *parts[1:]] + if len(parts) > 1 and (WARPER.match(parts[0]) or parts[0] == "pause") and parts[1].startswith("("): + parts = [parts[0], *self.split_bracket(parts[1]), *parts[2:]] # `ease (0.1)xpos 0.5` first = parts[0] i += 1 picture = self.image(line) if self.image and not line.block else None @@ -266,7 +293,7 @@ class AtlParser: i = self.compound(first, parts, lines, i - 1, steps) elif WARPER.match(first): self.move(first, parts, line, steps) - elif first in IGNORED: + elif first in IGNORED or first.startswith(COSMETIC): continue elif first in NAMED_POSITIONS: props = dict(NAMED_POSITIONS[first]) diff --git a/backend/app/converter/rpy_parser.py b/backend/app/converter/rpy_parser.py index f02980e..788953f 100644 --- a/backend/app/converter/rpy_parser.py +++ b/backend/app/converter/rpy_parser.py @@ -152,6 +152,9 @@ class ScriptData: self.warning_examples[kind] = where +TRANSLATED_SPEAKER = re.compile(r"""^_{1,2}\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')\s*\)(?=\s+["'])""") + + class RpyParser: def __init__(self, data: ScriptData, filename: str): self.d = data @@ -1274,7 +1277,7 @@ class RpyParser: spec["atl"] = fname spec["args"], spec["kw"] = e["a"], e["kw"] return spec - if "." not in fname and fname[:1].islower() and fname not in KNOWN_FACTORIES: + if "." not in fname and (fname[:1].islower() or fname in self.d.transform_names) and fname not in KNOWN_FACTORIES: # parameterised transform defined later (or in another file); the player resolves it by name self.use_transform(fname) return {"name": fname, "atl": fname, "args": e["a"], "kw": e["kw"]} @@ -1343,6 +1346,8 @@ class RpyParser: def screen_call(self, c: Cursor, line: Line) -> Node | None: """``name(args)`` after `call/show screen`; trailing `nopredict` and `with` are ignored.""" source = re.split(r"\s+(?:nopredict|with)\b", c.rest(), maxsplit=1)[0].strip() + # `onlayer name` only picks the stacking layer; the player keeps every screen in one layer + source = re.sub(r"\s+onlayer\s+\w+\s*$", "", source) call = self.expr(source, line) if source else None if call is None: self.d.warn("Не разобран вызов screen", self.where(line)) @@ -1623,6 +1628,9 @@ class RpyParser: # say ------------------------------------------------------------------------------ def say(self, line: Line) -> list[Node] | None: + # `__("Girl") "Hello"`: a translatable speaker name; the name is looked up by its text anyway + if wrapped := TRANSLATED_SPEAKER.match(line.text): + line = replace(line, text=wrapped[1] + line.text[wrapped.end():]) c = self.cursor(line) who: str | None = None who_str: str | None = None diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index 9436be4..dcec270 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -176,6 +176,7 @@ class ScreenParser: while i < len(lines): line = lines[i] text = line.text.rstrip(":").strip() if line.block else line.text.strip() + text = re.sub(r"^(add|text|label|image|textbutton)(?=[\"'])", r"\1 ", text) # `add"cover"` words = tokens(re.sub(r"^(if|elif|showif)\(", r"\1 (", text)) i += 1 if not words: @@ -260,8 +261,10 @@ class ScreenParser: return {"c": cond, "body": body, "props": props} def for_loop(self, line: Line) -> Node | None: + # `for item index item.id in items:`: the index only keys the widgets, the loop is the same + source = re.sub(r"\s+index\s+.+?\s+in\s+", " in ", line.text.strip().rstrip(":"), count=1) try: - stmt = pyexpr.parse_stmts(line.text.strip().rstrip(":") + ":\n pass")[0] + stmt = pyexpr.parse_stmts(source + ":\n pass")[0] except Unsupported as exc: self.warn(f"Цикл в screen: {exc}", line) return None diff --git a/backend/tests/test_screens.py b/backend/tests/test_screens.py index fb5fb84..36fc10d 100644 --- a/backend/tests/test_screens.py +++ b/backend/tests/test_screens.py @@ -194,3 +194,44 @@ def test_at_picks_a_transform_by_condition_and_atl_commas_split_statements(): handler = steps[1]["block"]["steps"] assert [s["op"] for s in handler] == ["set", "pause"] and handler[1]["t"] == 0.2 assert steps[1]["events"] == ["selected"] and steps[2]["events"] == ["deselected"] + + +def test_atl_warper_with_duration_in_brackets(): + data = parse('transform t:\n easein(1.0) zoom 1.3 ypos 1.0\n linear(0.5) xpos 0.2\n') + steps = data.atl["t"]["steps"] if "t" in data.atl else data.transforms["t"] + assert not data.blockers + assert [(s["warp"], s["t"]) for s in steps if s["op"] == "move"] == [("easein", 1.0), ("linear", 0.5)] + + +def test_atl_warper_brackets_glued_to_the_property(): + data = parse('transform t:\n ease(0.1)rotate 5\n easein(0.4)xpos 0.3\n') + steps = data.atl["t"]["steps"] if "t" in data.atl else data.transforms["t"] + assert not data.blockers + assert [(s["t"], sorted(s["props"])) for s in steps if s["op"] == "move"] == [(0.1, ["rotate"]), (0.4, ["xpos"])] + + +def test_atl_bracketed_durations_offset_and_gpu_hints(): + data = parse('transform t:\n ease (0.1)xpos 0.503\n linear 0.2 offset (3, 4)\n pause (0.8)\n' + ' shader "x"\n u_width 3.0\n transform_anchor True\n xpos 1.0\n') + steps = data.atl["t"]["steps"] if "t" in data.atl else data.transforms["t"] + assert not data.blockers + moves = [s for s in steps if s["op"] == "move"] + assert [m["t"] for m in moves] == [0.1, 0.2] + assert moves[0]["props"] == {"xpos": 0.503} and moves[1]["props"] == {"xoffset": 3, "yoffset": 4} + assert any(s["op"] == "pause" and s["t"] == 0.8 for s in steps) + + +def test_screen_for_index_glued_strings_and_onlayer(): + data = parse('screen s(items):\n for item index item.id in items:\n text"[item]"\n add"cover_0"\n' + 'label start:\n show screen s(items=[1]) onlayer hud\n call screen s([2]) onlayer overlay\n') + audit_ir(data) + require_supported_scripts(data) + body = data.screens["s"]["body"] + assert body[0]["c"] == "for" and body[0]["body"][0]["w"] == "text" and body[1]["w"] == "add" + + +def test_atl_pause_with_brackets(): + data = parse('transform t:\n ease(1.0) alpha 0.1\n pause(1.5)\n ease(1.0) alpha 0.8\n') + steps = data.atl["t"]["steps"] if "t" in data.atl else data.transforms["t"] + assert not data.blockers + assert [(s["op"], s["t"]) for s in steps] == [("move", 1.0), ("pause", 1.5), ("move", 1.0)]