feat: converter keeps layers of screens and camera, perspective, matrix calls; That New Teacher runs its own text-tag libraries

onlayer on show/hide/call screen and `camera <layer>` reach the player, `perspective`
is an ATL property now, RotateMatrix/OffsetMatrix/ScaleMatrix, Text.set_text and
renpy.fetch are accepted. The text-effect modules of That New Teacher are no longer
skipped (only the shader-based wave tag is).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-27 04:37:26 +03:00
1 parent 7652f7d010
commit 5da7ab1a90
6 files changed
+36 -14

No files matched your search

+1 -1
View File
@@ -43,7 +43,7 @@ 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_", "perspective", "point_to", "delay", "events", "nearest", "fps", "show_cancels_hide")
COSMETIC = ("shader", "mesh", "gl_", "u_", "point_to", "delay", "events", "nearest", "fps", "show_cancels_hide")
NAMED_POSITIONS = {
"left": {"xpos": 0.0, "xanchor": 0.0}, "right": {"xpos": 1.0, "xanchor": 1.0},
"center": {"xpos": 0.5, "xanchor": 0.5}, "truecenter": {"xpos": 0.5, "xanchor": 0.5, "ypos": 0.5, "yanchor": 0.5},
+4 -2
View File
@@ -199,7 +199,7 @@ METHODS |= NATIVE_METHODS
# Methods of generators made by `renpy.random.Random()` / `random.Random()`
METHODS |= {"send", "_duplicate", "_unique", "set_child", "get_placement", "subsurface", "update_state",
"adjusted_times", "get_range", "interpolate", "from_iterable", "itermonthdays", "isnumeric", "isdecimal", "add_style_property", "add_prefix_style_property", "add_pattern", "get_context", "read", "readlines", "close", "sub", "subn", "search", "match", "findall", "finditer", "split", "time", "_clear", "Enable", "Disable", "Toggle", "encode", "blit", "subpixel_blit", "fill", "canvas", "get_size", "add_shader", "add_uniform", "add_property",
"add_focus", "circle", "rect", "line", "lines", "polygon"}
"add_focus", "circle", "rect", "line", "lines", "polygon", "set_text"}
METHODS |= {"fullmatch", "hexdigest", "randint", "randrange", "random", "choice", "choices", "sample", "shuffle", "uniform"}
@@ -370,8 +370,10 @@ ENGINE_LABELS = {"main_menu_screen", "_main_menu_screen", "main_menu"}
# Calls that make the player wait; Python containing them runs on the player's generator interpreter
BLOCKING_BUILTINS = {"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement",
"renpy.call_screen", "renpy.movie_cutscene", "ui.interact"}
"renpy.call_screen", "renpy.movie_cutscene", "renpy.fetch", "ui.interact"}
CALLS |= {"renpy.free_memory", "achievement.clear_all", "renpy.image", "ImageReference", "SetMixer"}
# Geometry matrices of `matrixtransform` (frontend/src/player/rendertransform.ts)
CALLS |= {"RotateMatrix", "OffsetMatrix", "ScaleMatrix"}
CALLS |= BLOCKING_BUILTINS | {"renpy.show", "renpy.hide", "renpy.scene", "Borders", "renpy.set_tag_attributes",
"eval", "exec", "Replay", "EndReplay", "renpy.end_replay",
"renpy.get_showing_tags", "renpy.run", "renpy.pop_call",
+1 -1
View File
@@ -14,7 +14,7 @@ POSITION_PROPS = {"xpos", "ypos", "xanchor", "yanchor", "xalign", "yalign", "xce
"align", "pos", "anchor", "xysize", "size", "blur", "matrixcolor", "xycenter",
"xsize", "ysize", "crop", "xpan", "ypan", "xtile", "ytile", "xrotate", "yrotate", "zrotate", "zpos",
"zzoom", "blend", "additive", "transform_anchor", "rotate_pad", "matrixanchor", "matrixtransform",
"fit", "maxsize", "crop_relative"}
"fit", "maxsize", "crop_relative", "perspective"}
_SHOW_KEYWORDS = {"at", "as", "behind", "onlayer", "with", "zorder"}
+15 -5
View File
@@ -1227,8 +1227,10 @@ class RpyParser:
def st_camera(self, line: Line) -> list[Node]:
c = self.cursor(line)
c.keyword("camera")
c.word() if not c.peek("at") and not c.peek(":") else None
layer = c.word() if not c.peek("at") and not c.peek(":") else None
node: Node = {"op": "camera", "at": []}
if layer:
node["layer"] = layer
if c.keyword("at"):
for item in _split_top(c.until(set(), ":")):
node["at"].append(self.transform_ref(item, line))
@@ -1466,8 +1468,10 @@ 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)
layer_match = re.search(r"\s+onlayer\s+(\w+)\s*$", source)
layer = layer_match.group(1) if layer_match else None
if layer_match:
source = source[:layer_match.start()]
call = self.expr(source, line) if source else None
if call is None:
self.d.warn("Не разобран вызов screen", self.where(line))
@@ -1480,7 +1484,10 @@ class RpyParser:
self.d.warn("Не разобран вызов screen", self.where(line))
return None
self.use_screen(name)
return {"name": name, "args": args, "kw": kw}
found: Node = {"name": name, "args": args, "kw": kw}
if layer:
found["layer"] = layer
return found
def st_scene(self, line: Line) -> list[Node]:
c = self.cursor(line)
@@ -1521,7 +1528,10 @@ class RpyParser:
name = c.word()
if not name:
return []
return [{"op": "hide_screen", "name": name}] # hiding a screen that does not exist is harmless
hidden: Node = {"op": "hide_screen", "name": name} # hiding a screen that does not exist is harmless
if c.keyword("onlayer"):
hidden["layer"] = c.word()
return [hidden]
spec = self.image_spec(c, line)
node: Node = {"op": "hide", "tag": spec.get("tag", spec.get("name", [""])[0])}
self.show_clauses(c, line, node)
+4 -5
View File
@@ -94,11 +94,10 @@ BUILDS: dict[str, dict] = {
"id": "renpy/that-new-teacher-0.9.0",
# The Phone library's own statement parsers (ll.require, ll.keyword ...) run while Ren'Py reads scripts; the
# converter reads `init phone register` itself (converter/phone_register.py), so the file is not needed
# the old-save fixer reads Ren'Py's pickled saves (the browser keeps its own), and the text-effect modules draw
# letters through the render API: the player has its own {bt} {sc} {fi} {rotat} {glitch} {wave} (text.ts),
# and explode/gradient/ruby text is shown plain
"skip": ["01statements.rpy", "versioning/save_fixer.rpy", "explosion_text.rpy", "glitch_tag.rpy",
"gradient_tags.rpy", "kinetic_text_tags.rpy", "ruby.rpy", "wavey_tag.rpy",
# the old-save fixer reads Ren'Py's pickled saves (the browser keeps its own)
"skip": ["01statements.rpy", "versioning/save_fixer.rpy",
# the wave tag is a shader on every letter (the player has no shaders yet)
"wavey_tag.rpy",
# a shader blur patched into Ren's Transform; the player's own blur property needs no patch
"blur.rpy"],
# group_chats.rpy calls `charcter(char)`, a misspelling of its own `character`, in one branch
+11
View File
@@ -259,3 +259,14 @@ def test_input_widget_values_and_focus_actions_are_accepted():
body = data.screens["s"]["body"]
assert [n.get("w") for n in body[:3]] == ["input", "input", "textbutton"]
assert "default" in body[1]["props"] and "value" in body[0]["props"]
def test_layers_of_screens_and_camera_are_kept():
data = parse('screen s:\n text "x"\n'
'label start:\n show screen s onlayer hud\n call screen s onlayer overlay\n'
' hide screen s onlayer hud\n camera hud:\n perspective True\n zpos 5\n'
' camera:\n zoom 2\n')
body = data.modules["script.rpy"][0]["body"]
assert [(n["op"], n.get("layer")) for n in body] == [
("show_screen", "hud"), ("call_screen", "overlay"), ("hide_screen", "hud"), ("camera", "hud"), ("camera", None)]
assert body[3]["at"][-1]["props"] == {"perspective": True, "zpos": 5}