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 <noreply@anthropic.com>
This commit is contained in:
1 parent
25bd39dd43
commit
5c1e1aee01
4 files changed
+84
-5
No files matched your search
@@ -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"(?<![\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
|
||||
COSMETIC = ("shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate",
|
||||
COSMETIC = ("transform_anchor", "shader", "mesh", "gl_", "u_", "blend", "additive", "perspective", "point_to", "xrotate", "yrotate",
|
||||
"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},
|
||||
@@ -195,7 +196,7 @@ class AtlParser:
|
||||
if key in IGNORED or key.startswith(COSMETIC):
|
||||
_, i = read_value(parts, i + 1) if i + 1 < len(parts) else ("", i + 1)
|
||||
continue
|
||||
if key not in POSITION_PROPS:
|
||||
if key not in POSITION_PROPS and key != "offset":
|
||||
self.warn(f"Не поддерживается ATL: свойство {key}", line)
|
||||
return out
|
||||
if i + 1 >= 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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
Reference in new issue
Block a user