feat: Ren'Py screens (screen/call screen/show screen) with actions and styles

Converter
- screens.py parses the screen language: containers (fixed, frame, vbox, hbox,
  grid, vpgrid, viewport, button, has), widgets (text, textbutton, imagebutton,
  add, bar ...), if/for/use/transclude, default, python, key, timer, on, inline ATL,
  conditional properties, and decompiled forms (unparenthesised operators,
  glued property values, comma separated actions)
- style statements, call/show/hide screen operators and Python-level
  renpy.show_screen are translated; only screens the game shows are audited,
  unused stock screens no longer block an import; undefined screens do
- ATL values may be computed at run time and transforms take parameters
  (at blink(0.2, 0.3)); blur property

Player
- screens.ts builds the DOM from the IR and rebuilds it on state changes;
  buttons with idle/hover/selected/insensitive states, tooltips, sounds, modal
  screens, timers and keys that survive re-rendering, saved with the save file
- Actions: Jump, Call, Return, SetVariable, Toggle*, SetScreenVariable, Show, Hide,
  ToggleScreen, Function, Play, Notify, If, Language, ShowMenu, OpenURL ...
- Crop/LiveCrop/Solid/Frame/Transform/DynamicDisplayable values, crop property,
  globals(), renpy.variant/change_language/seen_image, transition stubs

Tests: screen parser and runtime (linkedom DOM), screens fixture compiled from
tests/fixtures/screens.rpy. Docs: docs/SCREENS.md.

Twisted-Thoughts now passes the strict compatibility check.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 13:17:11 +03:00
1 parent 47fc121e7d
commit 62ece25902
24 files changed
+2997 -60

No files matched your search

+227
View File
@@ -0,0 +1,227 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
import { parseHTML } from "linkedom";
const { window, document } = parseHTML("<!doctype html><html><body></body></html>");
Object.assign(globalThis, { window, document, HTMLElement: window.HTMLElement, HTMLInputElement: window.HTMLInputElement,
HTMLImageElement: window.HTMLImageElement, Image: window.Image });
window.setTimeout = setTimeout;
window.clearTimeout = clearTimeout;
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(performance.now()), 1);
const compiled = await build({
stdin: { contents: `export * from "./python"; export * from "./screens";`,
resolveDir: fileURLToPath(new URL("../src/player/", import.meta.url)), loader: "ts" },
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent",
});
const { Python, ScreenManager, userBuiltins } =
await import(`data:text/javascript;base64,${Buffer.from(compiled.outputFiles[0].text).toString("base64")}`);
const fixture = JSON.parse(readFileSync(new URL("./fixtures/screens.json", import.meta.url), "utf8"));
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
function setup(extra = {}) {
const store = { money: 20, inventory: ["sword", "shield"], can_cancel: true, ...extra };
const builtins = {};
const py = new Python({
get: (n) => (n in store ? store[n] : builtins[n]),
has: (n) => n in store || n in builtins,
set: (n, v) => { store[n] = v; },
del: (n) => { delete store[n]; },
});
const calls = { jump: [], play: [], notify: [], language: [] };
const host = {
py, baseSize: 24,
fileUrl: (p) => `/f/${p}`,
image: (value) => { const e = document.createElement("img"); e.setAttribute("src", `/f/${value}`); return e; },
text: (el, value) => { el.textContent = value.replace(/\[(\w+)\]/g, (_m, n) => String(py.lookup(n))); },
fontFamily: () => null,
play: (...a) => calls.play.push(a), stop() {}, notify: (m) => calls.notify.push(m),
jump: (l) => calls.jump.push(l), call() {}, quit() {}, mainMenu() {},
openMenu() {}, openUrl() {}, setLanguage: (c) => calls.language.push(c),
transform: (n) => fixture.transforms[n] ?? null,
atl: (n) => fixture.atl[n] ?? null,
};
const manager = new ScreenManager(host, fixture.screens, fixture.styles);
Object.assign(builtins, userBuiltins(py), manager.builtins());
document.body.append(manager.layer);
return { manager, store, calls, py };
}
const texts = (root) => [...root.querySelectorAll(".pg-stext")].map((e) => e.textContent);
const buttons = (root) => [...root.querySelectorAll(".pg-sbtn")];
const click = (el) => el.onclick({ stopPropagation() {}, preventDefault() {} });
const settle = () => wait(15);
test("a screen renders its widgets, conditions, loops and interpolated text", () => {
const { manager } = setup();
manager.show("hud", [2]);
const root = manager.layer;
assert.deepEqual(texts(root).slice(0, 2), ["Day 2", "Rich"]);
assert.deepEqual(buttons(root).map((b) => b.textContent), ["sword", "shield", "Menu", "Go"]);
const frame = root.querySelector(".pg-sframe");
assert.ok(frame, "frame rendered");
const label = root.querySelector(".pg-sbtn .pg-stext");
assert.equal(label.style.color, "#abc", "style hud_text applies through style_prefix");
assert.equal(label.style.fontSize, "30px");
assert.equal(root.querySelector(".pg-sbtn").style.background, "#123", "style hud_button applies");
});
test("conditions follow the game state when the screen refreshes", async () => {
const { manager, store } = setup();
manager.show("hud");
assert.ok(texts(manager.layer).includes("Rich"));
store.money = 1;
manager.refresh();
await settle();
assert.ok(texts(manager.layer).includes("Poor"));
});
test("actions change variables, screen variables and the selected state", async () => {
const { manager, store } = setup();
manager.show("hud");
click(buttons(manager.layer)[1]);
assert.equal(store.held, "shield");
assert.ok(!texts(manager.layer).includes("open!"));
click(buttons(manager.layer)[2]);
await settle();
assert.ok(texts(manager.layer).includes("open!"), "ToggleScreenVariable re-rendered the screen");
assert.ok(buttons(manager.layer)[2].classList.contains("pg-sselected"), "`selected open` marks the button");
});
test("Jump stops the remaining actions and goes to the label", () => {
const { manager, store, calls } = setup();
manager.show("hud");
click(buttons(manager.layer)[3]);
assert.equal(store.went, true);
assert.deepEqual(calls.jump, ["elsewhere"]);
});
test("call screen resolves with the Return value; a modal screen blocks what is below", async () => {
const { manager } = setup();
let result = "pending";
manager.show("shop", ["sword"], {}, { call: true, onReturn: (v) => { result = v; } });
const root = manager.layer;
assert.ok(root.querySelector(".pg-sinst").classList.contains("pg-smodal"));
const image = root.querySelector(".pg-simgbtn img");
assert.equal(image.getAttribute("src"), "/f/buy_idle.png", "auto \"buy_%s.png\" picks the idle image");
click(root.querySelector(".pg-simgbtn"));
assert.equal(result, "sword");
manager.hideCall("shop");
assert.equal(root.querySelector(".pg-sinst"), null);
});
test("insensitive buttons do nothing", () => {
const { manager } = setup({ can_cancel: false });
let result = "pending";
manager.show("shop", [], {}, { call: true, onReturn: (v) => { result = v; } });
const cancel = buttons(manager.layer)[1];
assert.ok(cancel.classList.contains("pg-sdisabled"));
click(cancel);
assert.equal(result, "pending");
});
test("timers fire, keys work, and hiding stops both", async () => {
const { manager, store } = setup();
manager.show("hud");
await wait(40);
assert.equal(store.ticked, true);
const press = () => {
const event = new window.Event("keydown");
event.key = "h";
window.dispatchEvent(event);
};
press();
assert.equal(store.hotkey, true);
store.hotkey = false;
manager.hide("hud");
press();
assert.equal(store.hotkey, false);
});
test("screens can be saved and restored with their arguments", () => {
const { manager } = setup();
manager.show("hud", [7]);
const saved = JSON.parse(JSON.stringify(manager.snapshot()));
manager.clear();
assert.equal(manager.layer.querySelector(".pg-sinst"), null);
manager.restore(saved);
assert.ok(texts(manager.layer).includes("Day 7"));
});
test("bars show a variable and unknown screens fail loudly", () => {
const { manager, store } = setup({ score: 5 });
manager.show("counter");
assert.equal(manager.layer.querySelector(".pg-sbar-fill").style.width, "50%");
store.score = 10;
assert.throws(() => manager.show("missing"), /missing/);
});
test("use with children transcludes them in the caller's scope; `has` nests the rest of the block", () => {
const { manager } = setup();
manager.show("deck");
const card = manager.layer.querySelector(".pg-sframe");
assert.ok(card.querySelector(".pg-svbox"), "`has vbox` created the box");
assert.deepEqual([...card.querySelectorAll(".pg-stext")].map((e) => e.textContent), ["Deck", "child one", "Pick"]);
assert.equal(card.querySelector(".pg-sbtn").textContent, "Pick");
});
test("on show runs once, on hide when the screen goes away", async () => {
const { manager, store } = setup();
manager.show("deck");
await settle();
assert.equal(store.shown, true);
store.shown = false;
manager.refresh();
await settle();
assert.equal(store.shown, false, "re-rendering does not repeat `on show`");
manager.hide("deck");
assert.equal(store.hid, true);
});
test("crop, glued properties, viewport grids, displayable values and extra actions", () => {
const { manager, calls } = setup();
manager.show("deck");
const root = manager.layer;
const pic = [...root.querySelectorAll(".pg-sadd")].find((e) => e.style.width === "30px");
assert.equal(pic.style.height, "40px", "crop (1, 2, 30, 40) shows a 30x40 window");
assert.equal(pic.firstChild.style.left, "-1px");
const small = [...root.querySelectorAll(".pg-sadd")].find((e) => e.style.width === "8px");
assert.ok(small, "Crop((0, 0, 8, 8), ...) works as a value");
const grid = root.querySelector(".pg-svpgrid");
assert.equal(grid.style.gridTemplateColumns, "repeat(2, auto)");
const x = [...root.querySelectorAll(".pg-stext")].find((e) => e.textContent === "x");
assert.equal(x.style.left, "calc(5px + 0px)", "pos(5, 6) without a space is understood");
const button = [...root.querySelectorAll(".pg-sbtn")].find((b) => b.textContent === "Language");
click(button);
assert.deepEqual(calls.language, ["ru"]);
assert.ok(manager.has("counter"));
});
test("animated transforms with parameters start from their arguments and do not replay", async () => {
const { manager } = setup();
manager.show("deck");
const dot = () => [...manager.layer.querySelectorAll(".pg-sadd")].find((e) => e.getAttribute("src") === "/f/dot.png");
assert.equal(dot().style.opacity, "0");
await wait(120);
assert.equal(dot().style.opacity, "1");
manager.refresh();
await settle();
assert.equal(dot().style.opacity, "1", "a finished animation stays finished after a refresh");
});
test("an `if` that only sets properties picks the image; decompiled comma actions run all parts", async () => {
const { manager, store } = setup();
manager.show("mood");
const picture = () => manager.layer.querySelector(".pg-simgbtn img").getAttribute("src");
assert.equal(picture(), "/f/rich.png");
store.money = 1;
manager.refresh();
await settle();
assert.equal(picture(), "/f/poor.png");
click(manager.layer.querySelector(".pg-simgbtn"));
assert.equal(store.mood_clicked, 1);
assert.equal(store.mood_done, true);
});