feat: player runtime for the Phone library and That New Teacher
CI / frontend (push) Failing after 17s
CI / backend (push) Failing after 34s

Named stores (PyNamespace: dotted access, functions that see their own store first, restored after a
save is loaded), real generators run step by step (next, send, yield as an expression), len/bool through
__len__/__bool__, os/calendar/itertools/pygame_sdl2 helpers, Skip and ToggleLocalVariable actions,
text tags {glitch} and {wave} and plain display of explode/gradient/ruby, SnowBlossom and anim.Blink,
RoundedCorners as a transform, transforms listed in `at`, ATL choice weights that are flags, and
renpy.image_size from the converter's picture sizes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-26 22:39:16 +03:00
1 parent 83cc7fdd5c
commit 11f87df712
15 files changed
+457 -24

No files matched your search

+3 -1
View File
@@ -231,8 +231,10 @@ export class AtlRunner {
await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event))); await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event)));
return true; return true;
case "choice": { case "choice": {
const weights = step.blocks.map((b) => this.seconds(b.w) || 1); // a weight is a number or a flag: False (0) means the branch is never taken
const weights = step.blocks.map((b) => { const w = Number(this.seconds(b.w)); return Number.isFinite(w) ? w : 1; });
const total = weights.reduce((s, w) => s + w, 0); const total = weights.reduce((s, w) => s + w, 0);
if (total <= 0) return true;
let pick = Math.random() * total; let pick = Math.random() * total;
const chosen = step.blocks.find((_, i) => (pick -= weights[i]) < 0) ?? step.blocks[step.blocks.length - 1]; const chosen = step.blocks.find((_, i) => (pick -= weights[i]) < 0) ?? step.blocks[step.blocks.length - 1];
if (chosen) await this.run(chosen.block, token, depth + 1, event); if (chosen) await this.run(chosen.block, token, depth + 1, event);
+66 -5
View File
@@ -4,7 +4,7 @@ import { AudioSystem } from "./audio";
import { cutscene } from "./cutscene"; import { cutscene } from "./cutscene";
import { VolumeStore } from "./volume-store"; import { VolumeStore } from "./volume-store";
import { LiveImages } from "../imager/live"; import { LiveImages } from "../imager/live";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python"; import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyLen, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { Renderer, type ResolvedImage } from "./renderer"; import { Renderer, type ResolvedImage } from "./renderer";
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text"; import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
import { languageName, rememberLanguage, type Translator } from "./i18n"; import { languageName, rememberLanguage, type Translator } from "./i18n";
@@ -226,7 +226,8 @@ export class Engine {
// --- files & images ---------------------------------------------------------- // --- files & images ----------------------------------------------------------
fileUrl(path: string): string | null { /** The name a file has in the manifest, from the name the game uses. */
logicalName(path: string): string | undefined {
const clean = path.replace(/^<[^>]*>/, "").replace(/\\/g, "/").replace(/^\/+/, ""); const clean = path.replace(/^<[^>]*>/, "").replace(/\\/g, "/").replace(/^\/+/, "");
let logical: string | undefined = this.manifest.files[clean] ? clean : undefined; let logical: string | undefined = this.manifest.files[clean] ? clean : undefined;
if (!logical) { if (!logical) {
@@ -236,6 +237,11 @@ export class Engine {
if (logical) break; if (logical) break;
} }
} }
return logical;
}
fileUrl(path: string): string | null {
const logical = this.logicalName(path);
const entry = logical ? this.manifest.files[logical] : undefined; const entry = logical ? this.manifest.files[logical] : undefined;
if (!entry || !logical) return null; if (!entry || !logical) return null;
const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`; const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`;
@@ -305,6 +311,7 @@ export class Engine {
movieLoop: value.kw.loop !== false }; movieLoop: value.kw.loop !== false };
case "text": return { text: pyStr(a) }; case "text": return { text: pyStr(a) };
case "null": return { composite: [] }; case "null": return { composite: [] };
case "snowblossom": return { snow: { img: this.imageDefinition(a, depth + 1), count: Math.min(60, Number(value.kw.count ?? b ?? 10) || 10), speed: Math.abs(Number(value.kw.yspeed ?? c ?? 100)) / 100 || 1 } };
case "animation": { // Animation(a, 3.0, b, 0.1, c): pictures with how long each stays case "animation": { // Animation(a, 3.0, b, 0.1, c): pictures with how long each stays
const frames: { img: ImageDef; d?: number }[] = []; const frames: { img: ImageDef; d?: number }[] = [];
for (let i = 0; i < value.args.length; i += 2) for (let i = 0; i < value.args.length; i += 2)
@@ -488,6 +495,16 @@ export class Engine {
list_files: () => Object.keys(this.game.files ?? {}), list_files: () => Object.keys(this.game.files ?? {}),
get_mouse_pos: () => [...this.mousePos], get_mouse_pos: () => [...this.mousePos],
has_live2d: () => false, has_live2d: () => false,
known_languages: () => (this.game.sourceLang ? [this.game.sourceLang, ...Object.keys(this.game.locales ?? {})] : []),
predict_screen: () => null,
predicting: () => false,
rollback: () => null,
invoke_in_new_context: (fn: any, ...a: any[]) => (typeof fn === "function" ? fn(...a) : null),
add_layer: () => null,
show_layer_at: () => null,
filter_text_tags: (text: any) => pyStr(text).replace(/\{[^}]*\}/g, ""),
text: { text: { Text: (...a: any[]) => new Displayable("text", a, {}) } },
easy: { displayable_or_none: (d: any) => d ?? null, displayable: (d: any) => d },
capture_focus: (name: any = "default") => { this.screens.captureFocus(pyStr(name)); return null; }, capture_focus: (name: any = "default") => { this.screens.captureFocus(pyStr(name)); return null; },
clear_capture_focus: (name: any = "default") => { this.screens.clearFocus(pyStr(name)); return null; }, clear_capture_focus: (name: any = "default") => { this.screens.clearFocus(pyStr(name)); return null; },
get_widget: () => null, get_widget: () => null,
@@ -514,13 +531,22 @@ export class Engine {
restart_interaction: () => { this.screens.refresh(); return null; }, restart_interaction: () => { this.screens.refresh(); return null; },
// there is no network access from the player: games that fetch news handle the failure themselves // there is no network access from the player: games that fetch news handle the failure themselves
fetch: () => { throw new Error("IOError: сеть недоступна"); }, fetch: () => { throw new Error("IOError: сеть недоступна"); },
display: { layout: { Container: containerClass(this.displayableBase, () => this.py) }, focus: { set_focused: () => null }, draw: { kill_textures: () => null }, render: { free_memory: () => null }, im: { Image: (path: any) => new Displayable("image", [path], {}) } }, display: { matrix: { Matrix: () => ({}), offset: () => ({}), rotate: () => ({}), perspective: () => ({}), texture_projection: () => ({}), screen_projection: () => ({}) },
layout: { AdjustTimes: (child: any) => child, Motion: (_move: any, _period: any, child: any) => child,
Container: containerClass(this.displayableBase, () => this.py) },
focus: { set_focused: () => null }, draw: { kill_textures: () => null }, render: { free_memory: () => null }, im: { Image: (path: any) => new Displayable("image", [path], {}) } },
jump_out_of_context: (label: string) => { jump_out_of_context: (label: string) => {
throw new JumpSignal("jump", label); throw new JumpSignal("jump", label);
}, },
image_size: (name: any) => { image_size: (name: any) => {
const text = pyStr(name); const text = pyStr(name);
const resolved = this.resolveImage(text.split(" ")); const resolved = this.resolveImage(text.split(" "));
const logical = this.logicalName(resolved?.def?.file ?? text);
const known = logical ? this.game.imageSizes?.[logical] : undefined;
if (known) {
const scale = this.game.assetScale?.[logical!] ?? 1; // a replaced picture may be bigger than the original
return [Math.round(known[0] / scale), Math.round(known[1] / scale)];
}
const url = this.fileUrl(resolved?.def?.file ?? text); const url = this.fileUrl(resolved?.def?.file ?? text);
const probe = url ? new Image() : null; const probe = url ? new Image() : null;
if (probe) probe.src = url!; if (probe) probe.src = url!;
@@ -670,6 +696,7 @@ export class Engine {
// games branch on the Ren'Py language name ("russian"); None means the source language // games branch on the Ren'Py language name ("russian"); None means the source language
preferences: this.languagePrefs(), preferences: this.languagePrefs(),
_preferences: this.languagePrefs(), _preferences: this.languagePrefs(),
Layer: (name: any) => ({ __layer: pyStr(name) }),
gui: { rebuild: () => null }, gui: { rebuild: () => null },
store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null, store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null,
set: (_t, k: string, v) => ((this.store[k] = v), true) }), set: (_t, k: string, v) => ((this.store[k] = v), true) }),
@@ -686,7 +713,7 @@ export class Engine {
log1p: Math.log1p, expm1: Math.expm1, isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity, log1p: Math.log1p, expm1: Math.expm1, isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity,
copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1), copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1),
}, },
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0), len: (x: any) => pyLen(x),
// a read-only view: `"name" in globals()` works, writes do not reach the game // a read-only view: `"name" in globals()` works, writes do not reach the game
globals: () => new PyDict(Object.entries(this.store)), globals: () => new PyDict(Object.entries(this.store)),
print: () => null, print: () => null,
@@ -736,6 +763,25 @@ export class Engine {
frozenset: (xs: any = []) => [...new Set(xs)], frozenset: (xs: any = []) => [...new Set(xs)],
cache: { get_total_size: () => 0, cache_limit: 1 }, cache: { get_total_size: () => 0, cache_limit: 1 },
FontGroup: () => fontGroup(), FontGroup: () => fontGroup(),
os: {
listdir: (path: any) => {
const prefix = pyStr(path).replaceAll("\\", "/").replace(/^\/+/, "").replace(/\/?$/, "/").toLowerCase();
const names = new Set<string>();
for (const file of Object.keys(this.game.files ?? {})) {
const lower = file.toLowerCase();
if (lower.startsWith(prefix)) names.add(file.slice(prefix.length).split("/")[0]);
}
return [...names];
},
path: {
join: (...parts: any[]) => parts.map(pyStr).join("/").replace(/\/+/g, "/"),
basename: (p: any) => pyStr(p).split("/").pop() ?? "",
dirname: (p: any) => pyStr(p).split("/").slice(0, -1).join("/"),
splitext: (p: any) => { const t = pyStr(p), i = t.lastIndexOf("."); return i > t.lastIndexOf("/") && i > 0 ? [t.slice(0, i), t.slice(i)] : [t, ""]; },
exists: (p: any) => this.fileUrl(pyStr(p)) !== null,
isfile: (p: any) => this.fileUrl(pyStr(p)) !== null,
},
},
json: { json: {
loads: (text: any) => fromJson(JSON.parse(pyStr(text))), loads: (text: any) => fromJson(JSON.parse(pyStr(text))),
dumps: (value: any) => JSON.stringify(toJson(value)), dumps: (value: any) => JSON.stringify(toJson(value)),
@@ -767,6 +813,8 @@ export class Engine {
}, },
get_mute: () => false, get_mute: () => false,
get_volume: () => 1, get_volume: () => 1,
set_volume: () => null,
set_mute: () => null,
}; };
} }
@@ -1029,7 +1077,17 @@ export class Engine {
saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm), saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm),
loadSlot: (slot) => void this.load(slot), loadSlot: (slot) => void this.load(slot),
deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm), deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm),
imageSize: (value) => {
const name = typeof value === "string" ? value : value instanceof Displayable && value.kind === "image" ? pyStr(value.args[0]) : null;
if (!name) return null;
const logical = this.logicalName(this.resolveImage(name.split(" "))?.def?.file ?? name);
const known = logical ? this.game.imageSizes?.[logical] : undefined;
if (!known) return null;
const scale = this.game.assetScale?.[logical!] ?? 1;
return [Math.round(known[0] / scale), Math.round(known[1] / scale)];
},
ask: (text) => this.confirmAsk(text, "OK"), ask: (text) => this.confirmAsk(text, "OK"),
setSkipping: (on) => this.setSkip(on),
get filePageName() { return String(engine.persistent._file_page_name ?? ""); }, get filePageName() { return String(engine.persistent._file_page_name ?? ""); },
set filePageName(name: string) { engine.persistent._file_page_name = name; }, set filePageName(name: string) { engine.persistent._file_page_name = name; },
get filePage() { return String(engine.persistent._file_page ?? "1"); }, get filePage() { return String(engine.persistent._file_page ?? "1"); },
@@ -1091,6 +1149,7 @@ export class Engine {
private newStore() { private newStore() {
this.resetStore(); this.resetStore();
for (const [name, e] of this.game.defaults) { for (const [name, e] of this.game.defaults) {
if (name.includes(".")) { this.py.assignPath(name, this.safeEval(e)); continue; } // `default phone.data = ...`
if (!(name in this.store)) this.store[name] = this.safeEval(e); if (!(name in this.store)) this.store[name] = this.safeEval(e);
} }
} }
@@ -1919,7 +1978,9 @@ export class Engine {
private applySave(data: SaveData) { private applySave(data: SaveData) {
this.resetStore(); this.resetStore();
for (const [name, definition] of Object.entries(data.registeredImages ?? {})) this.registerImage(name, definition); for (const [name, definition] of Object.entries(data.registeredImages ?? {})) this.registerImage(name, definition);
Object.assign(this.store, decodeValue(data.store)); const saved = decodeValue(data.store);
this.py.restoreNamespaces(saved);
Object.assign(this.store, saved);
this.screens.restore(decodeValue(data.screens ?? [])); this.screens.restore(decodeValue(data.screens ?? []));
this.renderer.scene(); this.renderer.scene();
for (const s of data.scene) { for (const s of data.scene) {
+8
View File
@@ -376,6 +376,14 @@
.pg-fx-sc { animation: pg-fx-scare 0.12s linear infinite; } .pg-fx-sc { animation: pg-fx-scare 0.12s linear infinite; }
.pg-fx-fi { animation: pg-fx-in 0.6s ease-out both; } .pg-fx-fi { animation: pg-fx-in 0.6s ease-out both; }
.pg-fx-rotat { animation: pg-fx-spin 2s linear infinite; } .pg-fx-rotat { animation: pg-fx-spin 2s linear infinite; }
.pg-fx-glitch { animation: pg-fx-glitch 0.35s steps(2, end) infinite; text-shadow: 1px 0 #f0f, -1px 0 #0ff; }
.pg-fx-wave { animation: pg-fx-wave 1.2s ease-in-out infinite; }
@keyframes pg-fx-glitch { 20% { transform: translate(-1px, 1px); } 40% { transform: translate(1px, -1px); } 60% { transform: translate(-2px, 0); } 80% { transform: translate(2px, 1px); } }
@keyframes pg-fx-wave { 50% { transform: translateY(-4px); } }
/* SnowBlossom: pictures that fall and drift while the scene is shown */
.pg-snow { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }
.pg-snow > * { position: absolute; top: -10%; animation: pg-snow-fall linear infinite; }
@keyframes pg-snow-fall { from { transform: translate(0, 0); } to { transform: translate(var(--drift, 40px), 120vh); } }
@keyframes pg-fx-bounce { 50% { transform: translateY(calc(var(--fx, 6) * -1px)); } } @keyframes pg-fx-bounce { 50% { transform: translateY(calc(var(--fx, 6) * -1px)); } }
@keyframes pg-fx-scare { 25% { transform: translate(1px, -1px); } 75% { transform: translate(-1px, 1px); } } @keyframes pg-fx-scare { 25% { transform: translate(1px, -1px); } 75% { transform: translate(-1px, 1px); } }
@keyframes pg-fx-in { from { opacity: 0; transform: translateY(8px); } } @keyframes pg-fx-in { from { opacity: 0; transform: translateY(8px); } }
+25
View File
@@ -254,6 +254,8 @@ class Hash {
} }
/** Namespaces added to the sandbox's builtins. */ /** Namespaces added to the sandbox's builtins. */
const iterateAll = (v: any): Iterable<any> => (Array.isArray(v) ? v : v && typeof v[Symbol.iterator] === "function" ? v : []);
export function pythonModules(): Record<string, any> { export function pythonModules(): Record<string, any> {
const re = { const re = {
I: 2, IGNORECASE: 2, M: 8, MULTILINE: 8, S: 16, DOTALL: 16, I: 2, IGNORECASE: 2, M: 8, MULTILINE: 8, S: 16, DOTALL: 16,
@@ -303,6 +305,8 @@ export function pythonModules(): Record<string, any> {
time: { time: {
time: () => Date.now() / 1000, time: () => Date.now() / 1000,
monotonic: () => performance.now() / 1000, monotonic: () => performance.now() / 1000,
gmtime: (seconds?: any) => { const d = new Date(typeof seconds === "number" ? seconds * 1000 : Date.now()); return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), (d.getUTCDay() + 6) % 7, 1, 0]; },
localtime: (seconds?: any) => { const d = new Date(typeof seconds === "number" ? seconds * 1000 : Date.now()); return [d.getFullYear(), d.getMonth() + 1, d.getDate(), d.getHours(), d.getMinutes(), d.getSeconds(), (d.getDay() + 6) % 7, 1, 0]; },
sleep: () => null, sleep: () => null,
strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()), strftime: (format: string, t?: any) => strftime(format, t instanceof DateTime ? t.d : new Date()),
}, },
@@ -344,6 +348,27 @@ export function pythonModules(): Record<string, any> {
.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i").test(String(name)), .replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i").test(String(name)),
}, },
traceback: { format_exc: () => "", print_exc: () => null }, traceback: { format_exc: () => "", print_exc: () => null },
itertools: {
chain: Object.assign((...lists: any[]) => lists.flatMap((l) => [...iterateAll(l)]), {
__statics: { from_iterable: (lists: any) => [...iterateAll(lists)].flatMap((l) => [...iterateAll(l)]) } }),
},
chain: Object.assign((...lists: any[]) => lists.flatMap((l) => [...iterateAll(l)]), {
__statics: { from_iterable: (lists: any) => [...iterateAll(lists)].flatMap((l) => [...iterateAll(l)]) } }),
calendar: {
Calendar: (firstweekday = 0) => ({
itermonthdays: (year: number, month: number) => {
const first = (new Date(year, month - 1, 1).getDay() + 6) % 7; // Monday = 0
const length = new Date(year, month, 0).getDate();
const lead = (first - firstweekday + 7) % 7;
const days = [...Array(lead).fill(0), ...Array.from({ length }, (_, i) => i + 1)];
while (days.length % 7) days.push(0);
return days;
},
}),
monthrange: (year: number, month: number) => [(new Date(year, month - 1, 1).getDay() + 6) % 7, new Date(year, month, 0).getDate()],
isleap: (year: number) => (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0,
},
pygame_sdl2: { power: { get_power_info: () => ({ percent: 100, state: "no_battery", seconds: -1 }) } },
hashlib: { hashlib: {
md5: (data?: any) => new Hash(md5, data), md5: (data?: any) => new Hash(md5, data),
sha256: (data?: any) => new Hash(sha256, data), sha256: (data?: any) => new Hash(sha256, data),
+181 -12
View File
@@ -56,6 +56,13 @@ export class BlockingCall {
constructor(public kind: string, public args: any[] = [], public kw: Record<string, any> = {}) {} constructor(public kind: string, public args: any[] = [], public kw: Record<string, any> = {}) {}
} }
/** What a generator hands to whoever drives it with next() or send(). */
export class UserYield extends BlockingCall {
constructor(public value: any) {
super("yield");
}
}
class ReturnSignal { class ReturnSignal {
constructor(public value: any) {} constructor(public value: any) {}
} }
@@ -67,9 +74,23 @@ export type Builtin = ((...args: any[]) => any) & { __py?: true };
// `attrs`, so a plain JSON round trip (saves) restores them given the class registry. // `attrs`, so a plain JSON round trip (saves) restores them given the class registry.
const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__", const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__",
"__getitem__", "__contains__"]); "__getitem__", "__contains__", "__len__", "__bool__"]);
const MAX_CALL_DEPTH = 150; const MAX_CALL_DEPTH = 150;
/**
* A named store (`init python in phone.discussion:`): names defined there live in this object, which the rest of the
* game reaches as `phone.discussion.name`. Its functions see its names first, then the game's.
*/
export class PyNamespace {
vars = new Map<string, any>();
constructor(public path: string) {}
toJSON() {
const a: Record<string, any> = {};
for (const [k, v] of this.vars) if (typeof v !== "function" && !(v instanceof PyClass)) a[k] = v;
return { __ns__: this.path, a };
}
}
export class PyClass { export class PyClass {
attrs = new Map<string, any>(); attrs = new Map<string, any>();
constructor(public name: string, public bases: PyClass[]) {} constructor(public name: string, public bases: PyClass[]) {}
@@ -250,8 +271,22 @@ function instanceText(v: PyInstance): string {
return `<${v.cls.name} object>`; return `<${v.cls.name} object>`;
} }
/** `len(x)`: strings, lists, dicts and instances whose class defines `__len__`. */
export function pyLen(x: any): number {
if (x instanceof PyDict) return x.map.size;
const own = userMethod(x, "__len__");
if (own) return Number(own(x));
return x?.length ?? 0;
}
export function truthy(v: any): boolean { export function truthy(v: any): boolean {
if (v === null || v === undefined || v === false || v === 0 || v === "") return false; if (v === null || v === undefined || v === false || v === 0 || v === "") return false;
if (v instanceof PyInstance && activePython) {
const flag = v.cls.find("__bool__");
if (flag && typeof flag.value === "function") return truthy(flag.value(v));
const size = v.cls.find("__len__");
if (size && typeof size.value === "function") return Number(size.value(v)) > 0;
}
if (Array.isArray(v)) return v.length > 0; if (Array.isArray(v)) return v.length > 0;
if (v instanceof PyDict) return v.map.size > 0; if (v instanceof PyDict) return v.map.size > 0;
return true; return true;
@@ -542,6 +577,8 @@ function methodOf(obj: any, name: string): any {
}, },
rfind: (x: string) => obj.lastIndexOf(x), rfind: (x: string) => obj.lastIndexOf(x),
isdigit: () => /^\d+$/.test(obj), isdigit: () => /^\d+$/.test(obj),
isnumeric: () => /^\p{N}+$/u.test(obj),
isdecimal: () => /^\p{Nd}+$/u.test(obj),
isalpha: () => /^\p{L}+$/u.test(obj), isalpha: () => /^\p{L}+$/u.test(obj),
}; };
return Object.hasOwn(m, name) ? m[name] : undefined; return Object.hasOwn(m, name) ? m[name] : undefined;
@@ -583,6 +620,7 @@ export class Python {
if (!safeProperty(name)) return null; if (!safeProperty(name)) return null;
for (let i = this.locals.length - 1; i >= 0; i--) for (let i = this.locals.length - 1; i >= 0; i--)
if (this.locals[i].has(name)) return this.locals[i].get(name); if (this.locals[i].has(name)) return this.locals[i].get(name);
if (this.ns?.vars.has(name)) return this.ns.vars.get(name);
if (this.scope.has(name)) return this.scope.get(name); if (this.scope.has(name)) return this.scope.get(name);
if (this.strict) this.raise("NameError", `имя ${name} не определено`); if (this.strict) this.raise("NameError", `имя ${name} не определено`);
this.warnOnce(`неизвестное имя ${name}`); this.warnOnce(`неизвестное имя ${name}`);
@@ -600,6 +638,7 @@ export class Python {
return this.lookup(e.id); return this.lookup(e.id);
case "attr": { case "attr": {
const o = this.eval(e.o); const o = this.eval(e.o);
if (o instanceof PyNamespace) return o.vars.has(e.a) ? o.vars.get(e.a) : null;
if (o instanceof PyInstance || o instanceof PyClass || o instanceof SuperProxy) { if (o instanceof PyInstance || o instanceof PyClass || o instanceof SuperProxy) {
if (!safePythonProperty(o, e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null; if (!safePythonProperty(o, e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null;
const v = this.attrOf(o, e.a); const v = this.attrOf(o, e.a);
@@ -780,6 +819,7 @@ export class Python {
} }
case "attr": { case "attr": {
const o = this.eval(t.o); const o = this.eval(t.o);
if (o instanceof PyNamespace) return void o.vars.set(t.a, value);
if (!safePythonProperty(o, t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен"); if (!safePythonProperty(o, t.a) || typeof o === "function") throw new Error("Служебный атрибут недоступен");
if (o instanceof PyInstance) { if (o instanceof PyInstance) {
const found = o.cls.find(t.a); const found = o.cls.find(t.a);
@@ -872,8 +912,17 @@ export class Python {
case "class": case "class":
this.defineClass(s); this.defineClass(s);
break; break;
case "yield": this.yielded.at(-1)?.push(s.v ? this.eval(s.v) : null); break; case "in_store": {
case "yield_from": this.yielded.at(-1)?.push(...iterate(this.eval(s.v))); break; const store = this.namespaceOf(s.ns);
const outside = this.ns;
this.ns = store;
try {
this.exec(s.body);
} finally {
this.ns = outside;
}
break;
}
case "return": case "return":
if (!this.fn) throw new Error("return вне функции"); if (!this.fn) throw new Error("return вне функции");
throw new ReturnSignal(s.v ? this.eval(s.v) : null); throw new ReturnSignal(s.v ? this.eval(s.v) : null);
@@ -959,7 +1008,7 @@ export class Python {
/** Assign a name in the scope the running code sees: function local, global or enclosing. */ /** Assign a name in the scope the running code sees: function local, global or enclosing. */
bindName(name: string, value: any) { bindName(name: string, value: any) {
const f = this.fn; const f = this.fn;
if (!f || f.globals.has(name)) return void this.scope.set(name, value); if (!f || f.globals.has(name)) return void (this.ns ? this.ns.vars.set(name, value) : this.scope.set(name, value));
if (f.nonlocals.has(name)) { if (f.nonlocals.has(name)) {
for (let i = this.locals.length - 2; i >= 0; i--) { for (let i = this.locals.length - 2; i >= 0; i--) {
if (this.locals[i].has(name)) return void this.locals[i].set(name, value); if (this.locals[i].has(name)) return void this.locals[i].set(name, value);
@@ -972,6 +1021,7 @@ export class Python {
makeFunction(def: FnDef, closure: Map<string, any>[], defaults: any[]): Builtin { makeFunction(def: FnDef, closure: Map<string, any>[], defaults: any[]): Builtin {
const fn: any = (...args: any[]) => this.callUser(fn, args); const fn: any = (...args: any[]) => this.callUser(fn, args);
fn.__py = true; fn.__py = true;
fn.__ns = this.ns;
fn.__def = def; fn.__def = def;
fn.__closure = closure; fn.__closure = closure;
fn.__defaults = defaults; fn.__defaults = defaults;
@@ -1024,6 +1074,8 @@ export class Python {
if (this.depth >= MAX_CALL_DEPTH) throw new Error("Слишком глубокая рекурсия в python"); if (this.depth >= MAX_CALL_DEPTH) throw new Error("Слишком глубокая рекурсия в python");
const previousLocals = this.locals; const previousLocals = this.locals;
const previousFn = this.fn; const previousFn = this.fn;
const previousNs = this.ns;
this.ns = fn.__ns ?? null;
this.depth++; this.depth++;
this.locals = [...fn.__closure, frame]; this.locals = [...fn.__closure, frame];
this.fn = { this.fn = {
@@ -1034,27 +1086,125 @@ export class Python {
this.depth--; this.depth--;
this.locals = previousLocals; this.locals = previousLocals;
this.fn = previousFn; this.fn = previousFn;
this.ns = previousNs;
}; };
} }
/** What the generator functions that are running have yielded so far (they run to the end, see PyIterator). */ /**
private yielded: any[][] = []; * A generator function's result: the body runs in the interpreter that can wait (execG), and every `yield` hands
* control back here. Its own scope is put back before each step and the caller's after it.
*/
private makeGenerator(fn: any, frame: Map<string, any>) {
const def: FnDef = fn.__def;
const owner = this;
let it: Generator<BlockingCall, void, any> | null = null;
let done = false;
const step = (sent: any): { done: true } | { done: false; value: any } => {
if (done) return { done: true };
const caller = { locals: this.locals, fn: this.fn, depth: this.depth, strict: this.strict, handling: this.handling };
let leave: (() => void) | null = null;
try {
if (!it) {
if (sent !== null && sent !== undefined) throw new TypeError("send() первого шага генератора принимает только None");
leave = this.enterFunction(fn, frame);
it = (function* () {
try {
yield* owner.execG(def.body ?? []);
} catch (err) {
if (!(err instanceof ReturnSignal)) throw err;
}
})();
}
const next = it.next(sent);
if (next.done) { done = true; return { done: true }; }
if (!(next.value instanceof UserYield)) throw new Error("Ожидание игрока внутри генератора не поддерживается");
return { done: false, value: next.value.value };
} catch (err) {
done = true;
throw err;
} finally {
leave?.();
Object.assign(this, caller);
}
};
const stop = () => this.raise("StopIteration", "");
const generator: any = {
__generator: true,
next: () => { const r = step(null); return r.done ? stop() : r.value; },
send: (value: any = null) => { const r = step(value); return r.done ? stop() : r.value; },
close: () => { done = true; return null; },
*[Symbol.iterator]() { for (;;) { const r = step(null); if (r.done) return; yield r.value; } },
};
return generator;
}
/** The store a running function or block belongs to (null: the game's own). */
ns: PyNamespace | null = null;
/** `phone.discussion`: the store with this dotted name, made when it is first needed. */
namespaceOf(path: string): PyNamespace {
const [root, ...rest] = path.split(".");
const known = this.scope.has(root) ? this.scope.get(root) : null;
let current: PyNamespace;
if (known instanceof PyNamespace) current = known;
else {
current = new PyNamespace(root);
this.scope.set(root, current);
}
let name = root;
for (const part of rest) {
name += `.${part}`;
const existing: any = current.vars.get(part);
let child: PyNamespace;
if (existing instanceof PyNamespace) child = existing;
else {
child = new PyNamespace(name);
current.vars.set(part, child);
}
current = child;
}
return current;
}
/** `default phone.data = ...`: assign to a name inside a store. */
assignPath(path: string, value: any) {
const at = path.lastIndexOf(".");
if (at < 0) return void this.scope.set(path, value);
this.namespaceOf(path.slice(0, at)).vars.set(path.slice(at + 1), value);
}
/** After a save is loaded: put its values into the stores the game's init code has just rebuilt. */
restoreNamespaces(saved: Record<string, any>) {
const merge = (target: PyNamespace, data: Record<string, any>) => {
for (const [k, v] of Object.entries(data)) {
if (v && typeof v === "object" && typeof v.__ns__ === "string") {
const child = target.vars.get(k);
merge(child instanceof PyNamespace ? child : this.namespaceOf(v.__ns__), v.a);
} else target.vars.set(k, v);
}
};
for (const [key, v] of Object.entries(saved)) {
if (v && typeof v === "object" && typeof v.__ns__ === "string") {
merge(this.namespaceOf(v.__ns__), v.a);
delete saved[key];
}
}
}
private callUser(fn: any, given: any[]): any { private callUser(fn: any, given: any[]): any {
const def: FnDef = fn.__def; const def: FnDef = fn.__def;
if (def.gen) return this.makeGenerator(fn, this.bindArguments(fn, given));
const leave = this.enterFunction(fn, this.bindArguments(fn, given)); const leave = this.enterFunction(fn, this.bindArguments(fn, given));
try { try {
if (def.expr) return this.eval(def.expr); if (def.expr) return this.eval(def.expr);
if (def.gen) this.yielded.push([]);
try { try {
this.exec(def.body ?? []); this.exec(def.body ?? []);
} catch (err) { } catch (err) {
if (!(err instanceof ReturnSignal)) throw err; if (err instanceof ReturnSignal) return err.value;
if (!def.gen) return err.value; throw err;
} }
return def.gen ? new PyIterator(this.yielded.at(-1) ?? []) : null; return null;
} finally { } finally {
if (def.gen) this.yielded.pop();
leave(); leave();
} }
} }
@@ -1067,7 +1217,7 @@ export class Python {
const known = this.blockMemo.get(node); const known = this.blockMemo.get(node);
if (known !== undefined) return known; if (known !== undefined) return known;
this.blockMemo.set(node, false); // recursion guard this.blockMemo.set(node, false); // recursion guard
let result = false; let result = node.op === "yield" || node.op === "yield_from" || node.k === "yield";
if (Array.isArray(node)) result = node.some((n) => this.mayBlock(n)); if (Array.isArray(node)) result = node.some((n) => this.mayBlock(n));
else if (node.op === "def" || node.op === "class" || node.k === "lambda") result = false; else if (node.op === "def" || node.op === "class" || node.k === "lambda") result = false;
else { else {
@@ -1111,6 +1261,12 @@ export class Python {
continue; continue;
} }
switch (s.op) { switch (s.op) {
case "yield":
yield* this.suspend(new UserYield(s.v ? yield* this.evalG(s.v) : null));
break;
case "yield_from":
for (const item of [...iterate(yield* this.evalG(s.v))]) yield* this.suspend(new UserYield(item));
break;
case "set": case "set":
this.bind(s.t, yield* this.evalG(s.v)); this.bind(s.t, yield* this.evalG(s.v));
break; break;
@@ -1208,6 +1364,10 @@ export class Python {
/** Evaluate an expression that may contain waiting calls. Everything else is delegated to eval(). */ /** Evaluate an expression that may contain waiting calls. Everything else is delegated to eval(). */
*evalG(e: Expr | null | undefined): Generator<BlockingCall, any, any> { *evalG(e: Expr | null | undefined): Generator<BlockingCall, any, any> {
if (e && e.k === "yield") {
const value = e.v ? yield* this.evalG(e.v) : null;
return yield* this.suspend(new UserYield(value));
}
if (!e) return null; if (!e) return null;
if (!this.mayBlock(e)) return this.eval(e); if (!this.mayBlock(e)) return this.eval(e);
const wrap = (value: any): Expr => ({ k: "const", v: value }); const wrap = (value: any): Expr => ({ k: "const", v: value });
@@ -1474,6 +1634,8 @@ export function decodeValue(v: any): any {
if (v && typeof v === "object") { if (v && typeof v === "object") {
if (Array.isArray(v.__dict__)) if (Array.isArray(v.__dict__))
return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)])); return new PyDict(v.__dict__.map(([k, x]: [any, any]) => [k, decodeValue(x)]));
if (typeof v.__ns__ === "string" && v.a && typeof v.a === "object")
return { __ns__: v.__ns__, a: Object.fromEntries(Object.entries(v.a).map(([k, x]) => [k, decodeValue(x)])) };
if (typeof v.__inst__ === "string" && v.a && typeof v.a === "object") { if (typeof v.__inst__ === "string" && v.a && typeof v.a === "object") {
const attrs: Record<string, any> = Object.create(null); const attrs: Record<string, any> = Object.create(null);
for (const [k, x] of Object.entries(v.a)) attrs[k] = decodeValue(x); for (const [k, x] of Object.entries(v.a)) attrs[k] = decodeValue(x);
@@ -1523,6 +1685,7 @@ export function userBuiltins(py: Python): Record<string, any> {
const get = (o: any, name: string): any => { const get = (o: any, name: string): any => {
if (typeof name !== "string" || (!safePythonProperty(o, name) && !SUPPORTED_DUNDERS.has(name))) return undefined; if (typeof name !== "string" || (!safePythonProperty(o, name) && !SUPPORTED_DUNDERS.has(name))) return undefined;
if (o instanceof PyInstance || o instanceof PyClass) return py.attrOf(o, name); if (o instanceof PyInstance || o instanceof PyClass) return py.attrOf(o, name);
if (o instanceof PyNamespace) return o.vars.get(name);
if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined; if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined;
if (o === null || o === undefined || typeof o === "function") return undefined; if (o === null || o === undefined || typeof o === "function") return undefined;
return methodOf(o, name) ?? (Object.hasOwn(Object(o), name) ? o[name] : undefined); return methodOf(o, name) ?? (Object.hasOwn(Object(o), name) ? o[name] : undefined);
@@ -1565,6 +1728,12 @@ export function userBuiltins(py: Python): Record<string, any> {
}, },
iter: (x: any) => new PyIterator([...iterate(x)]), iter: (x: any) => new PyIterator([...iterate(x)]),
next: (it: any, ...rest: any[]) => { next: (it: any, ...rest: any[]) => {
if (it && it.__generator) {
try { return it.next(); } catch (error) {
if (rest.length && error instanceof PyException) return rest[0];
throw error;
}
}
if (!(it instanceof PyIterator)) throw new Error("next() ожидает итератор"); if (!(it instanceof PyIterator)) throw new Error("next() ожидает итератор");
try { return it.next(rest[0], rest.length > 0); } catch (error) { try { return it.next(rest[0], rest.length > 0); } catch (error) {
if ((error as Error).message === "StopIteration") py.raise("StopIteration", ""); if ((error as Error).message === "StopIteration") py.raise("StopIteration", "");
+4
View File
@@ -132,6 +132,8 @@ export interface RenderHostOptions {
element(value: any): HTMLElement | null; element(value: any): HTMLElement | null;
/** Size of the area given to a displayable that has no size of its own. */ /** Size of the area given to a displayable that has no size of its own. */
area(): [number, number]; area(): [number, number];
/** Natural size of a picture the game names, known without loading it. */
imageSize?(value: any): [number, number] | null;
/** An event handler returned a value: it ends the interaction. */ /** An event handler returned a value: it ends the interaction. */
finish(value: any): void; finish(value: any): void;
} }
@@ -164,6 +166,8 @@ export class RenderHost {
} }
private measure(value: any): [number, number] { private measure(value: any): [number, number] {
const known = this.options.imageSize?.(value);
if (known) return known;
const element = this.options.element(value); const element = this.options.element(value);
if (!element) return [0, 0]; if (!element) return [0, 0];
const box = (this.measuring ??= document.createElement("div")); const box = (this.measuring ??= document.createElement("div"));
+13
View File
@@ -172,6 +172,19 @@ export class Renderer {
d.style.height = `${this.height}px`; d.style.height = `${this.height}px`;
return d; return d;
} }
if (def.snow) {
const field = document.createElement("div");
field.className = "pg-snow";
for (let i = 0; i < def.snow.count; i++) {
const piece = this.build(def.snow.img, depth + 1);
piece.style.left = `${(i * 37 + (i * i) % 23) % 100}%`;
piece.style.animationDuration = `${(6 + (i * 7) % 9) / Math.max(0.2, def.snow.speed)}s`;
piece.style.animationDelay = `${-((i * 13) % 11)}s`;
piece.style.setProperty("--drift", `${((i * 29) % 80) - 40}px`);
field.append(piece);
}
return field;
}
if (def.alphaMask) { if (def.alphaMask) {
const picture = this.build(def.alphaMask.img, depth + 1); const picture = this.build(def.alphaMask.img, depth + 1);
const maskEl = this.build(def.alphaMask.mask, depth + 1); const maskEl = this.build(def.alphaMask.mask, depth + 1);
+54 -4
View File
@@ -45,6 +45,8 @@ export interface ScreenHost {
openUrl(url: string): void; openUrl(url: string): void;
quickSave(): void; quickSave(): void;
quickLoad(): void; quickLoad(): void;
/** The Skip action: fast-forward through text already seen. */
setSkipping?(on: boolean): void;
/** Ask the player to confirm (Confirm action). */ /** Ask the player to confirm (Confirm action). */
ask(text: string): Promise<boolean>; ask(text: string): Promise<boolean>;
/** The name of the current save page (`FilePageNameInputValue`). */ /** The name of the current save page (`FilePageNameInputValue`). */
@@ -54,6 +56,8 @@ export interface ScreenHost {
saveSlot(slot: string, confirm: boolean): void; saveSlot(slot: string, confirm: boolean): void;
loadSlot(slot: string): void; loadSlot(slot: string): void;
deleteSlot(slot: string, confirm: boolean): void; deleteSlot(slot: string, confirm: boolean): void;
/** Natural size of a named picture (from the converter), or null. */
imageSize?(value: any): [number, number] | null;
/** Size of the game picture, for displayables that ask how much room they have. */ /** Size of the game picture, for displayables that ask how much room they have. */
resolution?: [number, number]; resolution?: [number, number];
/** The page of the save/load screen (`persistent._file_page`). */ /** The page of the save/load screen (`persistent._file_page`). */
@@ -174,6 +178,7 @@ export class ScreenManager {
py: this.host.py, py: this.host.py,
element: (value) => this.displayable(value), element: (value) => this.displayable(value),
area: () => this.host.resolution ?? [1920, 1080], area: () => this.host.resolution ?? [1920, 1080],
imageSize: (value) => this.host.imageSize?.(value) ?? null,
finish: (value) => this.interactionReturn?.(value), finish: (value) => this.interactionReturn?.(value),
})); }));
} }
@@ -336,6 +341,8 @@ export class ScreenManager {
this.pending = requestAnimationFrame(() => { this.pending = requestAnimationFrame(() => {
this.pending = 0; this.pending = 0;
if (this.suspended) return; if (this.suspended) return;
// A press may have started after this frame was queued by a timer or hover.
if (this.pressed) { this.refreshAfterPress = true; return; }
for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst); for (const inst of [...this.shown.values()]) if (this.shown.get(inst.name) === inst) this.render(inst);
}); });
} }
@@ -1147,6 +1154,25 @@ export class ScreenManager {
} }
return child; return child;
} }
case "blink": { // anim.Blink(picture): fades in and out
const picture = this.displayable(a, depth + 1);
if (picture) picture.style.animation = "pg-blink 1.2s ease-in-out infinite";
return picture;
}
case "snowblossom": { // falling pictures
const field = document.createElement("div");
field.className = "pg-snow";
const count = Math.min(60, Number(value.kw.count ?? b ?? 10) || 10);
for (let i = 0; i < count; i++) {
const piece = this.displayable(a, depth + 1);
if (!piece) break;
piece.style.left = `${(i * 37 + (i * i) % 23) % 100}%`;
piece.style.animationDuration = `${6 + (i * 7) % 9}s`;
piece.style.animationDelay = `${-((i * 13) % 11)}s`;
field.append(piece);
}
return field;
}
case "conditionswitch": { // ConditionSwitch("cond", picture, ...): the first condition that holds case "conditionswitch": { // ConditionSwitch("cond", picture, ...): the first condition that holds
for (let i = 0; i + 1 < value.args.length; i += 2) { for (let i = 0; i + 1 < value.args.length; i += 2) {
const holds = typeof value.args[i] === "string" ? truthy(this.host.py.eval(parseExpression(value.args[i]))) : truthy(value.args[i]); const holds = typeof value.args[i] === "string" ? truthy(this.host.py.eval(parseExpression(value.args[i]))) : truthy(value.args[i]);
@@ -1546,11 +1572,16 @@ export class ScreenManager {
const named = (value: any): AtSpec => { const named = (value: any): AtSpec => {
const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value
: value && typeof value.__transform === "string" ? value.__transform : ""; : value && typeof value.__transform === "string" ? value.__transform : "";
if (value && value.__rounded !== undefined) return { props: { rounded: value.__rounded } };
return name ? { name, atl: this.host.atl(name) ? name : undefined } : {}; return name ? { name, atl: this.host.atl(name) ? name : undefined } : {};
}; };
const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else) const pick = (spec: AtSpec): AtSpec[] => {
: spec.dyn ? named(this.ev(spec.dyn)) : spec); if (spec.choose) return pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else);
const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : []; if (!spec.dyn) return [spec];
const value = this.ev(spec.dyn);
return (Array.isArray(value) ? value : [value]).filter((v) => v !== null && v !== undefined).map(named); // a list of transforms
};
const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.flatMap(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);
@@ -1562,6 +1593,11 @@ export class ScreenManager {
if (p.rotate) parts.push(`rotate(${p.rotate}deg)`); if (p.rotate) parts.push(`rotate(${p.rotate}deg)`);
el.style.transform = parts.join(" "); el.style.transform = parts.join(" ");
if (p.alpha !== undefined) el.style.opacity = String(p.alpha); if (p.alpha !== undefined) el.style.opacity = String(p.alpha);
if (p.rounded !== undefined) { // RoundedCorners(radius): one radius or four (the game's own shader)
const radius = Array.isArray(p.rounded) ? p.rounded.map((r: number) => `${r}px`).join(" ") : `${p.rounded}px`;
el.style.borderRadius = radius;
el.style.overflow = "hidden";
}
applyFilters(el, p); applyFilters(el, p);
}; };
for (const spec of specs) { for (const spec of specs) {
@@ -1744,6 +1780,13 @@ export class ScreenManager {
case "InvertSelected": this.perform(a, owner); return; case "InvertSelected": this.perform(a, owner); return;
case "UploadSync": case "UploadSync":
case "DownloadSync": this.host.notify("Синхронизация недоступна в браузере"); return; case "DownloadSync": this.host.notify("Синхронизация недоступна в браузере"); return;
case "Skip": this.host.setSkipping?.(true); return;
case "Rollback": return; // there is no rollback in the browser player
case "ToggleLocalVariable": {
const now = owner?.vars.get(a);
owner?.vars.set(a, !truthy(now));
return;
}
case "Confirm": { case "Confirm": {
const yes = b, no = c; const yes = b, no = c;
void this.host.ask(pyStr(a)).then((ok) => { this.run(ok ? yes : no, owner); }); void this.host.ask(pyStr(a)).then((ok) => { this.run(ok ? yes : no, owner); });
@@ -1844,7 +1887,7 @@ export class ScreenManager {
"OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership", "OpenURL", "SetLocalVariable", "QuickSave", "QuickLoad", "Replay", "EndReplay", "ToggleSetMembership",
"ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot", "ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot",
"FilePage", "FilePageNext", "FilePagePrevious", "CaptureFocus", "ClearFocus", "InvertSelected", "UploadSync", "FilePage", "FilePageNext", "FilePagePrevious", "CaptureFocus", "ClearFocus", "InvertSelected", "UploadSync",
"DownloadSync", "Confirm"]) out[kind] = make(kind); "DownloadSync", "Confirm", "Skip", "Rollback", "ToggleLocalVariable"]) out[kind] = make(kind);
// what an `input` edits: classes games can extend (`class Typing(VariableInputValue)` overrides set_text) // what an `input` edits: classes games can extend (`class Typing(VariableInputValue)` overrides set_text)
const inputBase = new PyClass("InputValue", [OBJECT]); const inputBase = new PyClass("InputValue", [OBJECT]);
const native = (cls: PyClass, name: string, fn: (self: PyInstance, ...a: any[]) => any) => cls.attrs.set(name, nativeMethod(fn)); const native = (cls: PyClass, name: string, fn: (self: PyInstance, ...a: any[]) => any) => cls.attrs.set(name, nativeMethod(fn));
@@ -1961,6 +2004,13 @@ export class ScreenManager {
out.Text = shape("text"); out.Text = shape("text");
out.RenpyText = shape("text"); out.RenpyText = shape("text");
out.Animation = shape("animation"); out.Animation = shape("animation");
out.SnowBlossom = shape("snowblossom");
out.anim = { Blink: shape("blink") };
const rounded = (...a: any[]) => ({ __rounded: (a[a.length - 1]?.__kw ?? {}).radius ?? a[1] ?? 0 });
out.RoundedCorners = rounded;
out.CurriedRoundedCorners = rounded;
out.ExplodeText = (child: any) => child; // the letter-explosion effect is shown as the plain text
out.MouseDisplayable = () => null; // a custom mouse picture: the browser keeps its own cursor
out.ConditionSwitch = shape("conditionswitch"); out.ConditionSwitch = shape("conditionswitch");
out.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText` out.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText`
out.MatrixColor = shape("matrixcolor"); out.MatrixColor = shape("matrixcolor");
+5 -1
View File
@@ -99,7 +99,7 @@ export function parseText(text: string): ParsedText {
case "size": case "size":
stack.push({ ...top, size: value, tag: name }); stack.push({ ...top, size: value, tag: name });
break; break;
case "bt": case "sc": case "fi": case "rotat": // Kinetic Text Tags case "bt": case "sc": case "fi": case "rotat": case "glitch": case "wave": // letters that move (text tags of the games)
stack.push({ ...top, fx: name, fxArg: value, tag: name }); stack.push({ ...top, fx: name, fxArg: value, tag: name });
break; break;
case "w": case "w":
@@ -121,6 +121,10 @@ export function parseText(text: string): ParsedText {
case "vspace": case "vspace":
push("\n"); push("\n");
break; break;
case "explode": case "explodehalf": case "gradient": case "gradient2": case "fadegrad": case "fadegradfi":
case "ruby": case "note": case "chaos": case "swap": case "move": case "omega": // shown as plain text
stack.push({ ...top, tag: name });
break;
case "a": case "font": case "cps": case "alpha": case "k": case "plain": case "a": case "font": case "cps": case "alpha": case "k": case "plain":
case "outlinecolor": case "rt": case "rb": case "art": case "shader": case "outlinecolor": case "rt": case "rb": case "art": case "shader":
stack.push({ ...top, tag: name }); // styling we ignore but must still balance stack.push({ ...top, tag: name }); // styling we ignore but must still balance
+4
View File
@@ -26,6 +26,8 @@ export interface ImageDef {
composite?: { pos: [number, number]; img: ImageDef }[]; composite?: { pos: [number, number]; img: ImageDef }[];
/** `AlphaMask(img, mask)`: the picture, visible where the mask is. */ /** `AlphaMask(img, mask)`: the picture, visible where the mask is. */
alphaMask?: { img: ImageDef; mask: ImageDef }; alphaMask?: { img: ImageDef; mask: ImageDef };
/** `SnowBlossom(img, count, xspeed, yspeed)`: particles that fall across the picture. */
snow?: { img: ImageDef; count: number; speed: number };
size?: [number, number] | null; size?: [number, number] | null;
text?: string; text?: string;
textExpr?: Expr; textExpr?: Expr;
@@ -110,6 +112,8 @@ export interface ScriptModule {
export interface GameData { export interface GameData {
/** JSON files of the game (paths inside game/); their text is in `dataText` once loaded. */ /** JSON files of the game (paths inside game/); their text is in `dataText` once loaded. */
dataFiles?: string[]; dataFiles?: string[];
/** Natural size of each converted picture, by its name in the manifest. */
imageSizes?: Record<string, [number, number]>;
dataText?: Record<string, string>; dataText?: Record<string, string>;
build?: Record<string, any>; build?: Record<string, any>;
/** Natural image dimensions are divided by this factor after an asset replacement. */ /** Natural image dimensions are divided by this factor after an asset replacement. */
+1 -1
View File
@@ -1 +1 @@
[{"op":"class","name":"Perk","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"key","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"v":{"k":"name","id":"key"}}],"decos":[]},{"op":"def","name":"__hash__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"hash"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"key"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__eq__","params":[{"n":"self","d":null,"kind":"p"},{"n":"other","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"ops":["=="],"r":[{"k":"attr","o":{"k":"name","id":"other"},"a":"key"}]}}],"decos":[]}]},{"op":"class","name":"Bag","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"items","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"v":{"k":"name","id":"items"}}],"decos":[]},{"op":"def","name":"__iter__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__getitem__","params":[{"n":"self","d":null,"kind":"p"},{"n":"i","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"sub","o":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"i":{"k":"name","id":"i"}}}],"decos":[]},{"op":"def","name":"__contains__","params":[{"n":"self","d":null,"kind":"p"},{"n":"x","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"name","id":"x"},"ops":["in"],"r":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}]}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"table"},"v":{"k":"dict","v":[]}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":1}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":2}},{"op":"set","t":{"k":"name","id":"result_size"},"v":{"k":"call","f":{"k":"name","id":"len"},"a":[{"k":"name","id":"table"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_value"},"v":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}}},{"op":"set","t":{"k":"name","id":"bag"},"v":{"k":"call","f":{"k":"name","id":"Bag"},"a":[{"k":"list","v":[{"k":"const","v":3},{"k":"const","v":4},{"k":"const","v":5}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_loop"},"v":{"k":"comp","e":{"k":"bin","op":"*","l":{"k":"name","id":"x"},"r":{"k":"const","v":2}},"gens":[{"t":{"k":"name","id":"x"},"it":{"k":"name","id":"bag"},"if":[]}]}},{"op":"set","t":{"k":"name","id":"result_item"},"v":{"k":"sub","o":{"k":"name","id":"bag"},"i":{"k":"const","v":1}}},{"op":"set","t":{"k":"name","id":"result_in"},"v":{"k":"cmp","l":{"k":"const","v":4},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"set","t":{"k":"name","id":"result_out"},"v":{"k":"cmp","l":{"k":"const","v":9},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"def","name":"walk","params":[{"n":"values","d":null,"kind":"p"},{"n":"kind","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"name","id":"seen"},"v":{"k":"list","v":[]}},{"op":"for","t":{"k":"name","id":"value"},"it":{"k":"name","id":"values"},"body":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}},"then":[{"op":"yield","v":{"k":"name","id":"value"}}],"else":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"list"}],"kw":{}},"then":[{"op":"yield_from","v":{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}}}],"else":[]}]}],"else":[]}],"decos":[],"gen":true},{"op":"set","t":{"k":"name","id":"result_found"},"v":{"k":"call","f":{"k":"name","id":"list"},"a":[{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":"a"},{"k":"list","v":[{"k":"const","v":2},{"k":"const","v":"b"},{"k":"list","v":[{"k":"const","v":3}]}]},{"k":"const","v":4}]},{"k":"name","id":"int"}],"kw":{}}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_iter"},"v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"list","v":[{"k":"const","v":7},{"k":"const","v":8}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_first"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_second"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_default"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"},{"Line truncated [{"op":"class","name":"Perk","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"key","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"v":{"k":"name","id":"key"}}],"decos":[]},{"op":"def","name":"__hash__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"hash"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"key"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__eq__","params":[{"n":"self","d":null,"kind":"p"},{"n":"other","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"attr","o":{"k":"name","id":"self"},"a":"key"},"ops":["=="],"r":[{"k":"attr","o":{"k":"name","id":"other"},"a":"key"}]}}],"decos":[]}]},{"op":"class","name":"Bag","bases":[],"body":[{"op":"def","name":"__init__","params":[{"n":"self","d":null,"kind":"p"},{"n":"items","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"v":{"k":"name","id":"items"}}],"decos":[]},{"op":"def","name":"__iter__","params":[{"n":"self","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}],"kw":{}}}],"decos":[]},{"op":"def","name":"__getitem__","params":[{"n":"self","d":null,"kind":"p"},{"n":"i","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"sub","o":{"k":"attr","o":{"k":"name","id":"self"},"a":"items"},"i":{"k":"name","id":"i"}}}],"decos":[]},{"op":"def","name":"__contains__","params":[{"n":"self","d":null,"kind":"p"},{"n":"x","d":null,"kind":"p"}],"body":[{"op":"return","v":{"k":"cmp","l":{"k":"name","id":"x"},"ops":["in"],"r":[{"k":"attr","o":{"k":"name","id":"self"},"a":"items"}]}}],"decos":[]}]},{"op":"set","t":{"k":"name","id":"table"},"v":{"k":"dict","v":[]}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":1}},{"op":"set","t":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}},"v":{"k":"const","v":2}},{"op":"set","t":{"k":"name","id":"result_size"},"v":{"k":"call","f":{"k":"name","id":"len"},"a":[{"k":"name","id":"table"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_value"},"v":{"k":"sub","o":{"k":"name","id":"table"},"i":{"k":"call","f":{"k":"name","id":"Perk"},"a":[{"k":"const","v":"a"}],"kw":{}}}},{"op":"set","t":{"k":"name","id":"bag"},"v":{"k":"call","f":{"k":"name","id":"Bag"},"a":[{"k":"list","v":[{"k":"const","v":3},{"k":"const","v":4},{"k":"const","v":5}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_loop"},"v":{"k":"comp","e":{"k":"bin","op":"*","l":{"k":"name","id":"x"},"r":{"k":"const","v":2}},"gens":[{"t":{"k":"name","id":"x"},"it":{"k":"name","id":"bag"},"if":[]}]}},{"op":"set","t":{"k":"name","id":"result_item"},"v":{"k":"sub","o":{"k":"name","id":"bag"},"i":{"k":"const","v":1}}},{"op":"set","t":{"k":"name","id":"result_in"},"v":{"k":"cmp","l":{"k":"const","v":4},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"set","t":{"k":"name","id":"result_out"},"v":{"k":"cmp","l":{"k":"const","v":9},"ops":["in"],"r":[{"k":"name","id":"bag"}]}},{"op":"def","name":"walk","params":[{"n":"values","d":null,"kind":"p"},{"n":"kind","d":null,"kind":"p"}],"body":[{"op":"set","t":{"k":"name","id":"seen"},"v":{"k":"list","v":[]}},{"op":"for","t":{"k":"name","id":"value"},"it":{"k":"name","id":"values"},"body":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}},"then":[{"op":"yield","v":{"k":"name","id":"value"}}],"else":[{"op":"if","c":{"k":"call","f":{"k":"name","id":"isinstance"},"a":[{"k":"name","id":"value"},{"k":"name","id":"list"}],"kw":{}},"then":[{"op":"yield_from","v":{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"name","id":"value"},{"k":"name","id":"kind"}],"kw":{}}}],"else":[]}]}],"else":[]}],"decos":[],"gen":true},{"op":"set","t":{"k":"name","id":"result_found"},"v":{"k":"call","f":{"k":"name","id":"list"},"a":[{"k":"call","f":{"k":"name","id":"walk"},"a":[{"k":"list","v":[{"k":"const","v":1},{"k":"const","v":"a"},{"k":"list","v":[{"k":"const","v":2},{"k":"const","v":"b"},{"k":"list","v":[{"k":"const","v":3}]}]},{"k":"const","v":4}]},{"k":"name","id":"int"}],"kw":{}}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_iter"},"v":{"k":"call","f":{"k":"name","id":"iter"},"a":[{"k":"list","v":[{"k":"const","v":7},{"k":"const","v":8}]}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_first"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_second"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"}],"kw":{}}},{"op":"set","t":{"k":"name","id":"result_default"},"v":{"k":"call","f":{"k":"name","id":"next"},"a":[{"k":"name","id":"result_iter"},{"Line truncated
+12
View File
@@ -49,3 +49,15 @@ result_iter = iter([7, 8])
result_first = next(result_iter) result_first = next(result_iter)
result_second = next(result_iter) result_second = next(result_iter)
result_default = next(result_iter, "done") result_default = next(result_iter, "done")
def coro():
got = yield 1
other = yield got + 1
yield other * 2
g = coro()
result_a = next(g)
result_b = g.send(10)
result_c = g.send(5)
+32
View File
@@ -8,6 +8,38 @@ const runtime = original ? JSON.parse(readFileSync(original, "utf8")) : null;
const click = (element) => element.onclick({ stopPropagation() {}, preventDefault() {} }); const click = (element) => element.onclick({ stopPropagation() {}, preventDefault() {} });
const enter = (engine, label) => engine.restart(() => engine.guardedJump(label)); const enter = (engine, label) => engine.restart(() => engine.guardedJump(label));
test("MIST's actual movie Skip survives a queued redraw and returns before its timer", { skip: !runtime }, async () => {
const { engine, root, window } = makeEngine(runtime);
globalThis.HTMLVideoElement = window.HTMLVideoElement ?? class {};
const played = [], stopped = [];
engine.audio.play = (...args) => played.push(args);
engine.audio.stop = (...args) => stopped.push(args);
engine.audio.mediaOf = () => null;
try {
engine.newStore();
Object.assign(engine.store, { movielength: 63, movieplaying: "images/animations/movies/Intro_Movie.webm" });
const completed = engine.exec({ op: "call_screen", name: "custommoviescreen", args: [], kw: {} });
const button = [...root.querySelectorAll(".pg-sbtn")].find((element) => element.textContent === "Skip");
assert.ok(button);
assert.equal(button.classList.contains("pg-sdisabled"), false);
engine.screens.refresh();
window.dispatchEvent(new window.Event("pointerdown"));
await until(() => engine.store.movielength < 63);
assert.equal(button.isConnected, true);
window.dispatchEvent(new window.Event("pointerup"));
click(button);
await completed;
assert.equal(engine.store._return, 0);
assert.ok(engine.store.movielength > 60, "Skip returns without waiting for the movie timer");
assert.equal(root.querySelector(".pg-sbtn"), null);
assert.equal(played[0][0], "movie");
assert.equal(stopped.at(-1)[0], "movie");
} finally {
window.dispatchEvent(new window.Event("pointercancel"));
engine.destroy();
}
});
test("MIST's actual raise-hand loop accepts screen input and resumes after a save", { skip: !runtime }, async () => { test("MIST's actual raise-hand loop accepts screen input and resumes after a save", { skip: !runtime }, async () => {
const { engine, root } = makeEngine(runtime); const { engine, root } = makeEngine(runtime);
try { try {
+27
View File
@@ -129,4 +129,31 @@ test("generator functions hand out what they yield; iter and next walk a sequenc
assert.equal(store.result_first, 7); assert.equal(store.result_first, 7);
assert.equal(store.result_second, 8); assert.equal(store.result_second, 8);
assert.equal(store.result_default, "done"); assert.equal(store.result_default, "done");
assert.deepEqual([store.result_a, store.result_b, store.result_c], [1, 11, 10]); // send() answers the yield
});
test("named stores keep their names apart, are reached through dotted paths, and survive a save", () => {
const { py, store } = runtime();
const call = (f, ...a) => ({ k: "call", f, a, kw: {} });
const name = (id) => ({ k: "name", id });
const attr = (o, a) => ({ k: "attr", o, a });
const c = (v) => ({ k: "const", v });
py.exec([{ op: "in_store", ns: "phone.chat", body: [
{ op: "set", t: name("counter"), v: c(0) },
{ op: "def", name: "bump", params: [], body: [
{ op: "global", names: ["counter"] },
{ op: "set", t: name("counter"), v: { k: "bin", op: "+", l: name("counter"), r: c(1) } },
{ op: "return", v: name("counter") }] },
] }]);
assert.equal(store.counter, undefined, "the name stays inside the store");
assert.equal(py.eval(call(attr(attr(name("phone"), "chat"), "bump"))), 1);
assert.equal(py.eval(call(attr(attr(name("phone"), "chat"), "bump"))), 2);
py.assignPath("phone.chat.mode", "night");
assert.equal(py.eval(attr(attr(name("phone"), "chat"), "mode")), "night");
const saved = JSON.parse(JSON.stringify({ phone: store.phone }));
assert.equal(saved.phone.a.chat.a.counter, 2);
assert.equal(saved.phone.a.chat.a.bump, undefined, "functions are rebuilt by init, not saved");
py.assignPath("phone.chat.counter", 99);
py.restoreNamespaces({ phone: decodeValue(saved).phone });
assert.equal(py.eval(attr(attr(name("phone"), "chat"), "counter")), 2);
}); });
+22
View File
@@ -294,3 +294,25 @@ test("a redraw keeps the playing video, and one held down button is not rebuilt
window.dispatchEvent(new window.Event("pointerup")); window.dispatchEvent(new window.Event("pointerup"));
await wait(30); await wait(30);
}); });
test("a redraw queued before pointerdown preserves the Return button until click", async () => {
const { manager } = setup();
const returned = [];
try {
manager.show("shop", ["skipped"], {}, { call: true, onReturn: (value) => returned.push(value) });
const button = buttons(manager.layer).find((element) => element.textContent === "Cancel");
manager.refresh(); // MIST's movie timer (or hover) queues the next frame.
window.dispatchEvent(new window.Event("pointerdown"));
await settle();
assert.equal(button.isConnected, true, "a queued redraw must also respect a held pointer");
window.dispatchEvent(new window.Event("pointerup"));
click(button);
assert.deepEqual(returned, [null]);
await settle();
assert.notEqual(buttons(manager.layer).find((element) => element.textContent === "Cancel"), button,
"the deferred redraw runs after the click");
} finally {
window.dispatchEvent(new window.Event("pointercancel"));
manager.destroy();
}
});