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>
228 lines
9.6 KiB
JavaScript
228 lines
9.6 KiB
JavaScript
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);
|
|
});
|