From b3f435f416d619c4616ab4054f50114348b2eb1c Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:21:55 +0300 Subject: [PATCH] feat: conditional "at" transforms in screens and comma-separated ATL statements `frame at (a if cond else b)` is kept as a choice that the player resolves each time the screen is drawn. An ATL line splits at top-level commas into separate statements, so `zoom 1.2, 0.2` is a zoom followed by a 0.2s pause, as Ren'Py compiles it (two-value properties such as `pos 0.5, 0.5` stay whole). The inert events deselected/unselected, which Ren'Py never sends, are accepted. With the build profile for The Only Doe 0.05 the game now passes the strict script check. Co-Authored-By: Claude Sonnet 5.5 --- backend/app/converter/atl.py | 50 +++++++++++++++++++++++++++++++- backend/app/converter/screens.py | 17 ++++++++++- backend/tests/test_screens.py | 16 ++++++++++ frontend/src/player/screens.ts | 3 +- frontend/src/player/types.ts | 2 ++ 5 files changed, 85 insertions(+), 3 deletions(-) diff --git a/backend/app/converter/atl.py b/backend/app/converter/atl.py index ca2e53f..a82a6cb 100644 --- a/backend/app/converter/atl.py +++ b/backend/app/converter/atl.py @@ -52,7 +52,9 @@ NAMED_POSITIONS = { "topright": {"xpos": 1.0, "xanchor": 1.0, "ypos": 0.0, "yanchor": 0.0}, } 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", + # Ren'Py itself never sends these (its selected states are the selected_* ones): the handler is inert + "deselected", "unselected"} GLUED_NUMBER = re.compile(r"^(\d+(?:\.\d*)?|\.\d+)([A-Za-z_]\w*)$") @@ -102,6 +104,40 @@ def _split(text: str) -> list[str]: return out +#: properties that take two numbers; `pos 0.5, 0.5` stays one statement +TWO_VALUE = {"pos", "anchor", "align", "offset", "around", "size", "crop", "xysize", "xycenter", "alignaround"} + + +def statements(text: str) -> list[str]: + """Split an ATL line at commas outside brackets and quotes: `zoom 1.2, 0.2` is `zoom 1.2` then a pause.""" + pieces, cur, depth, quote = [], [], 0, "" + for ch in text: + if quote: + quote = "" if ch == quote else quote + elif ch in "\"'": + quote = ch + elif ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + elif ch == "," and depth == 0: + pieces.append("".join(cur).strip()) + cur = [] + continue + cur.append(ch) + pieces.append("".join(cur).strip()) + merged: list[str] = [] + for piece in pieces: + if not piece: + continue + previous = merged[-1].split() if merged else [] + if len(previous) == 2 and previous[0] in TWO_VALUE and len(piece.split()) == 1: + merged[-1] = f"{merged[-1]} {piece}" + else: + merged.append(piece) + return merged + + class AtlParser: def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None], on_use: Callable[[str], None] = lambda name: None, @@ -188,7 +224,19 @@ class AtlParser: # --- block ---------------------------------------------------------------------- + def expand(self, lines: list[Line]) -> list[Line]: + """Lines holding several comma-separated statements become one line per statement.""" + out: list[Line] = [] + for line in lines: + pieces = statements(line.text.strip()) if not line.block and "," in line.text else [] + if len(pieces) > 1: + out.extend(Line(piece, line.lineno, line.indent, line.physical) for piece in pieces) + else: + out.append(line) + return out + def block(self, lines: list[Line]) -> Block: + lines = self.expand(lines) steps: list[dict[str, Any]] = [] repeat: Any = None i = 0 diff --git a/backend/app/converter/screens.py b/backend/app/converter/screens.py index 2784aec..9436be4 100644 --- a/backend/app/converter/screens.py +++ b/backend/app/converter/screens.py @@ -401,6 +401,21 @@ class ScreenParser: spec["atl"] = animation props["at"] = [*props.get("at", []), spec] + def at_ref(self, source: str, line: Line) -> Node: + """One `at` entry; `(a if cond else b)` picks a transform while the screen is shown.""" + text = source.strip() + if text.startswith("(") and text.endswith(")"): + try: + tree = ast.parse(text, mode="eval").body + except SyntaxError: + tree = None + if isinstance(tree, ast.IfExp): + cond = self.expr(ast.unparse(tree.test), line) + if cond is not None: + return {"choose": {"c": cond, "then": self.at_ref(ast.unparse(tree.body), line), + "else": self.at_ref(ast.unparse(tree.orelse), line)}} + return self.transform_ref(source, line) + def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]: props: dict[str, Any] = {} i = 0 @@ -434,7 +449,7 @@ class ScreenParser: i += 1 if not more: break - props["at"] = [self.transform_ref(item, line) for item in items if item] + props["at"] = [self.at_ref(item, line) for item in items if item] continue if glued is not None: source, i = glued, i + 1 diff --git a/backend/tests/test_screens.py b/backend/tests/test_screens.py index 32276b0..fb5fb84 100644 --- a/backend/tests/test_screens.py +++ b/backend/tests/test_screens.py @@ -178,3 +178,19 @@ def test_mousearea_is_a_leaf_with_hover_actions(): require_supported_scripts(data) node = data.screens["s"]["body"][0] assert node["w"] == "mousearea" and {"hovered", "unhovered", "xysize"} <= set(node["props"]) + + +def test_at_picks_a_transform_by_condition_and_atl_commas_split_statements(): + data = parse('transform grow:\n zoom 1.0\n on selected:\n zoom 1.2, 0.2\n on deselected:\n' + ' zoom 1.0, 0.2\ntransform still:\n zoom 1.0\n' + 'screen s(flag):\n frame at (grow if flag else still):\n text "x"\n' + 'label start:\n call screen s(True)\n') + audit_ir(data) + require_supported_scripts(data) + node = data.screens["s"]["body"][0] + choice = node["props"]["at"][0]["choose"] + assert choice["then"]["name"] == "grow" and choice["else"]["name"] == "still" + steps = data.atl["grow"]["steps"] + 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"] diff --git a/frontend/src/player/screens.ts b/frontend/src/player/screens.ts index 937bc27..070f58e 100644 --- a/frontend/src/player/screens.ts +++ b/frontend/src/player/screens.ts @@ -1287,7 +1287,8 @@ export class ScreenManager { /** `at` transforms: static properties now, animated ones through the ATL runner. */ private transforms(el: HTMLElement, props: Record, ctx: Ctx) { - const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at : []; + const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else) : spec); + const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : []; const live: Props = {}; const apply = (p: Props) => { const zoom = (p.zoom ?? 1); diff --git a/frontend/src/player/types.ts b/frontend/src/player/types.ts index 88ebfcf..ef49594 100644 --- a/frontend/src/player/types.ts +++ b/frontend/src/player/types.ts @@ -54,6 +54,8 @@ export interface Layer { } export interface AtSpec { + /** `at (a if cond else b)`: the entry is chosen each time the screen is drawn. */ + choose?: { c: Expr; then: AtSpec; else: AtSpec }; pos?: string; name?: string; props?: Record;