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 <noreply@anthropic.com>
This commit is contained in:
1 parent
0822c76321
commit
b3f435f416
5 files changed
+85
-3
No files matched your search
@@ -52,7 +52,9 @@ NAMED_POSITIONS = {
|
|||||||
"topright": {"xpos": 1.0, "xanchor": 1.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",
|
||||||
|
# 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*)$")
|
GLUED_NUMBER = re.compile(r"^(\d+(?:\.\d*)?|\.\d+)([A-Za-z_]\w*)$")
|
||||||
@@ -102,6 +104,40 @@ def _split(text: str) -> list[str]:
|
|||||||
return out
|
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:
|
class AtlParser:
|
||||||
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None],
|
def __init__(self, warn: Callable[[str, Line], None], is_static: Callable[[str], dict | None],
|
||||||
on_use: Callable[[str], None] = lambda name: None,
|
on_use: Callable[[str], None] = lambda name: None,
|
||||||
@@ -188,7 +224,19 @@ class AtlParser:
|
|||||||
|
|
||||||
# --- block ----------------------------------------------------------------------
|
# --- 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:
|
def block(self, lines: list[Line]) -> Block:
|
||||||
|
lines = self.expand(lines)
|
||||||
steps: list[dict[str, Any]] = []
|
steps: list[dict[str, Any]] = []
|
||||||
repeat: Any = None
|
repeat: Any = None
|
||||||
i = 0
|
i = 0
|
||||||
|
|||||||
@@ -401,6 +401,21 @@ class ScreenParser:
|
|||||||
spec["atl"] = animation
|
spec["atl"] = animation
|
||||||
props["at"] = [*props.get("at", []), spec]
|
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]:
|
def properties(self, words: list[str], line: Line, lenient: bool = False) -> dict[str, Any]:
|
||||||
props: dict[str, Any] = {}
|
props: dict[str, Any] = {}
|
||||||
i = 0
|
i = 0
|
||||||
@@ -434,7 +449,7 @@ class ScreenParser:
|
|||||||
i += 1
|
i += 1
|
||||||
if not more:
|
if not more:
|
||||||
break
|
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
|
continue
|
||||||
if glued is not None:
|
if glued is not None:
|
||||||
source, i = glued, i + 1
|
source, i = glued, i + 1
|
||||||
|
|||||||
@@ -178,3 +178,19 @@ def test_mousearea_is_a_leaf_with_hover_actions():
|
|||||||
require_supported_scripts(data)
|
require_supported_scripts(data)
|
||||||
node = data.screens["s"]["body"][0]
|
node = data.screens["s"]["body"][0]
|
||||||
assert node["w"] == "mousearea" and {"hovered", "unhovered", "xysize"} <= set(node["props"])
|
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"]
|
||||||
@@ -1287,7 +1287,8 @@ export class ScreenManager {
|
|||||||
|
|
||||||
/** `at` transforms: static properties now, animated ones through the ATL runner. */
|
/** `at` transforms: static properties now, animated ones through the ATL runner. */
|
||||||
private transforms(el: HTMLElement, props: Record<string, Expr>, ctx: Ctx) {
|
private transforms(el: HTMLElement, props: Record<string, Expr>, 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 live: Props = {};
|
||||||
const apply = (p: Props) => {
|
const apply = (p: Props) => {
|
||||||
const zoom = (p.zoom ?? 1);
|
const zoom = (p.zoom ?? 1);
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ export interface Layer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AtSpec {
|
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;
|
pos?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
props?: Record<string, any>;
|
props?: Record<string, any>;
|
||||||
|
|||||||
Reference in new issue
Block a user