feat: player runtime for the Phone library and That New Teacher
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:
1 parent
83cc7fdd5c
commit
11f87df712
15 files changed
+457
-24
No files matched your search
@@ -231,8 +231,10 @@ export class AtlRunner {
|
||||
await Promise.all(step.blocks.map((b) => this.run(b, token, depth + 1, event)));
|
||||
return true;
|
||||
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);
|
||||
if (total <= 0) return true;
|
||||
let pick = Math.random() * total;
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AudioSystem } from "./audio";
|
||||
import { cutscene } from "./cutscene";
|
||||
import { VolumeStore } from "./volume-store";
|
||||
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 { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
@@ -226,7 +226,8 @@ export class Engine {
|
||||
|
||||
// --- 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(/^\/+/, "");
|
||||
let logical: string | undefined = this.manifest.files[clean] ? clean : undefined;
|
||||
if (!logical) {
|
||||
@@ -236,6 +237,11 @@ export class Engine {
|
||||
if (logical) break;
|
||||
}
|
||||
}
|
||||
return logical;
|
||||
}
|
||||
|
||||
fileUrl(path: string): string | null {
|
||||
const logical = this.logicalName(path);
|
||||
const entry = logical ? this.manifest.files[logical] : undefined;
|
||||
if (!entry || !logical) return null;
|
||||
const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`;
|
||||
@@ -305,6 +311,7 @@ export class Engine {
|
||||
movieLoop: value.kw.loop !== false };
|
||||
case "text": return { text: pyStr(a) };
|
||||
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
|
||||
const frames: { img: ImageDef; d?: number }[] = [];
|
||||
for (let i = 0; i < value.args.length; i += 2)
|
||||
@@ -488,6 +495,16 @@ export class Engine {
|
||||
list_files: () => Object.keys(this.game.files ?? {}),
|
||||
get_mouse_pos: () => [...this.mousePos],
|
||||
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; },
|
||||
clear_capture_focus: (name: any = "default") => { this.screens.clearFocus(pyStr(name)); return null; },
|
||||
get_widget: () => null,
|
||||
@@ -514,13 +531,22 @@ export class Engine {
|
||||
restart_interaction: () => { this.screens.refresh(); return null; },
|
||||
// there is no network access from the player: games that fetch news handle the failure themselves
|
||||
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) => {
|
||||
throw new JumpSignal("jump", label);
|
||||
},
|
||||
image_size: (name: any) => {
|
||||
const text = pyStr(name);
|
||||
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 probe = url ? new Image() : null;
|
||||
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
|
||||
preferences: this.languagePrefs(),
|
||||
_preferences: this.languagePrefs(),
|
||||
Layer: (name: any) => ({ __layer: pyStr(name) }),
|
||||
gui: { rebuild: () => null },
|
||||
store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null,
|
||||
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,
|
||||
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
|
||||
globals: () => new PyDict(Object.entries(this.store)),
|
||||
print: () => null,
|
||||
@@ -736,6 +763,25 @@ export class Engine {
|
||||
frozenset: (xs: any = []) => [...new Set(xs)],
|
||||
cache: { get_total_size: () => 0, cache_limit: 1 },
|
||||
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: {
|
||||
loads: (text: any) => fromJson(JSON.parse(pyStr(text))),
|
||||
dumps: (value: any) => JSON.stringify(toJson(value)),
|
||||
@@ -767,6 +813,8 @@ export class Engine {
|
||||
},
|
||||
get_mute: () => false,
|
||||
get_volume: () => 1,
|
||||
set_volume: () => null,
|
||||
set_mute: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1029,7 +1077,17 @@ export class Engine {
|
||||
saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm),
|
||||
loadSlot: (slot) => void this.load(slot),
|
||||
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"),
|
||||
setSkipping: (on) => this.setSkip(on),
|
||||
get filePageName() { return String(engine.persistent._file_page_name ?? ""); },
|
||||
set filePageName(name: string) { engine.persistent._file_page_name = name; },
|
||||
get filePage() { return String(engine.persistent._file_page ?? "1"); },
|
||||
@@ -1091,6 +1149,7 @@ export class Engine {
|
||||
private newStore() {
|
||||
this.resetStore();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1919,7 +1978,9 @@ export class Engine {
|
||||
private applySave(data: SaveData) {
|
||||
this.resetStore();
|
||||
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.renderer.scene();
|
||||
for (const s of data.scene) {
|
||||
|
||||
@@ -376,6 +376,14 @@
|
||||
.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-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-scare { 25% { transform: translate(1px, -1px); } 75% { transform: translate(-1px, 1px); } }
|
||||
@keyframes pg-fx-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
|
||||
@@ -254,6 +254,8 @@ class Hash {
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
const re = {
|
||||
I: 2, IGNORECASE: 2, M: 8, MULTILINE: 8, S: 16, DOTALL: 16,
|
||||
@@ -303,6 +305,8 @@ export function pythonModules(): Record<string, any> {
|
||||
time: {
|
||||
time: () => Date.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,
|
||||
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)),
|
||||
},
|
||||
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: {
|
||||
md5: (data?: any) => new Hash(md5, data),
|
||||
sha256: (data?: any) => new Hash(sha256, data),
|
||||
|
||||
+181
-12
@@ -56,6 +56,13 @@ export class BlockingCall {
|
||||
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 {
|
||||
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.
|
||||
|
||||
const SUPPORTED_DUNDERS = new Set(["__init__", "__str__", "__repr__", "__call__", "__eq__", "__hash__", "__iter__",
|
||||
"__getitem__", "__contains__"]);
|
||||
"__getitem__", "__contains__", "__len__", "__bool__"]);
|
||||
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 {
|
||||
attrs = new Map<string, any>();
|
||||
constructor(public name: string, public bases: PyClass[]) {}
|
||||
@@ -250,8 +271,22 @@ function instanceText(v: PyInstance): string {
|
||||
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 {
|
||||
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 (v instanceof PyDict) return v.map.size > 0;
|
||||
return true;
|
||||
@@ -542,6 +577,8 @@ function methodOf(obj: any, name: string): any {
|
||||
},
|
||||
rfind: (x: string) => obj.lastIndexOf(x),
|
||||
isdigit: () => /^\d+$/.test(obj),
|
||||
isnumeric: () => /^\p{N}+$/u.test(obj),
|
||||
isdecimal: () => /^\p{Nd}+$/u.test(obj),
|
||||
isalpha: () => /^\p{L}+$/u.test(obj),
|
||||
};
|
||||
return Object.hasOwn(m, name) ? m[name] : undefined;
|
||||
@@ -583,6 +620,7 @@ export class Python {
|
||||
if (!safeProperty(name)) return null;
|
||||
for (let i = this.locals.length - 1; i >= 0; i--)
|
||||
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.strict) this.raise("NameError", `имя ${name} не определено`);
|
||||
this.warnOnce(`неизвестное имя ${name}`);
|
||||
@@ -600,6 +638,7 @@ export class Python {
|
||||
return this.lookup(e.id);
|
||||
case "attr": {
|
||||
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 (!safePythonProperty(o, e.a) && !SUPPORTED_DUNDERS.has(e.a)) return null;
|
||||
const v = this.attrOf(o, e.a);
|
||||
@@ -780,6 +819,7 @@ export class Python {
|
||||
}
|
||||
case "attr": {
|
||||
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 (o instanceof PyInstance) {
|
||||
const found = o.cls.find(t.a);
|
||||
@@ -872,8 +912,17 @@ export class Python {
|
||||
case "class":
|
||||
this.defineClass(s);
|
||||
break;
|
||||
case "yield": this.yielded.at(-1)?.push(s.v ? this.eval(s.v) : null); break;
|
||||
case "yield_from": this.yielded.at(-1)?.push(...iterate(this.eval(s.v))); break;
|
||||
case "in_store": {
|
||||
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":
|
||||
if (!this.fn) throw new Error("return вне функции");
|
||||
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. */
|
||||
bindName(name: string, value: any) {
|
||||
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)) {
|
||||
for (let i = this.locals.length - 2; i >= 0; i--) {
|
||||
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 {
|
||||
const fn: any = (...args: any[]) => this.callUser(fn, args);
|
||||
fn.__py = true;
|
||||
fn.__ns = this.ns;
|
||||
fn.__def = def;
|
||||
fn.__closure = closure;
|
||||
fn.__defaults = defaults;
|
||||
@@ -1024,6 +1074,8 @@ export class Python {
|
||||
if (this.depth >= MAX_CALL_DEPTH) throw new Error("Слишком глубокая рекурсия в python");
|
||||
const previousLocals = this.locals;
|
||||
const previousFn = this.fn;
|
||||
const previousNs = this.ns;
|
||||
this.ns = fn.__ns ?? null;
|
||||
this.depth++;
|
||||
this.locals = [...fn.__closure, frame];
|
||||
this.fn = {
|
||||
@@ -1034,27 +1086,125 @@ export class Python {
|
||||
this.depth--;
|
||||
this.locals = previousLocals;
|
||||
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 {
|
||||
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));
|
||||
try {
|
||||
if (def.expr) return this.eval(def.expr);
|
||||
if (def.gen) this.yielded.push([]);
|
||||
try {
|
||||
this.exec(def.body ?? []);
|
||||
} catch (err) {
|
||||
if (!(err instanceof ReturnSignal)) throw err;
|
||||
if (!def.gen) return err.value;
|
||||
if (err instanceof ReturnSignal) return err.value;
|
||||
throw err;
|
||||
}
|
||||
return def.gen ? new PyIterator(this.yielded.at(-1) ?? []) : null;
|
||||
return null;
|
||||
} finally {
|
||||
if (def.gen) this.yielded.pop();
|
||||
leave();
|
||||
}
|
||||
}
|
||||
@@ -1067,7 +1217,7 @@ export class Python {
|
||||
const known = this.blockMemo.get(node);
|
||||
if (known !== undefined) return known;
|
||||
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));
|
||||
else if (node.op === "def" || node.op === "class" || node.k === "lambda") result = false;
|
||||
else {
|
||||
@@ -1111,6 +1261,12 @@ export class Python {
|
||||
continue;
|
||||
}
|
||||
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":
|
||||
this.bind(s.t, yield* this.evalG(s.v));
|
||||
break;
|
||||
@@ -1208,6 +1364,10 @@ export class Python {
|
||||
|
||||
/** Evaluate an expression that may contain waiting calls. Everything else is delegated to eval(). */
|
||||
*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 (!this.mayBlock(e)) return this.eval(e);
|
||||
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 (Array.isArray(v.__dict__))
|
||||
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") {
|
||||
const attrs: Record<string, any> = Object.create(null);
|
||||
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 => {
|
||||
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 PyNamespace) return o.vars.get(name);
|
||||
if (o instanceof PyDict) return o.has(name) ? o.get(name) : undefined;
|
||||
if (o === null || o === undefined || typeof o === "function") return 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)]),
|
||||
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() ожидает итератор");
|
||||
try { return it.next(rest[0], rest.length > 0); } catch (error) {
|
||||
if ((error as Error).message === "StopIteration") py.raise("StopIteration", "");
|
||||
|
||||
@@ -132,6 +132,8 @@ export interface RenderHostOptions {
|
||||
element(value: any): HTMLElement | null;
|
||||
/** Size of the area given to a displayable that has no size of its own. */
|
||||
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. */
|
||||
finish(value: any): void;
|
||||
}
|
||||
@@ -164,6 +166,8 @@ export class RenderHost {
|
||||
}
|
||||
|
||||
private measure(value: any): [number, number] {
|
||||
const known = this.options.imageSize?.(value);
|
||||
if (known) return known;
|
||||
const element = this.options.element(value);
|
||||
if (!element) return [0, 0];
|
||||
const box = (this.measuring ??= document.createElement("div"));
|
||||
|
||||
@@ -172,6 +172,19 @@ export class Renderer {
|
||||
d.style.height = `${this.height}px`;
|
||||
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) {
|
||||
const picture = this.build(def.alphaMask.img, depth + 1);
|
||||
const maskEl = this.build(def.alphaMask.mask, depth + 1);
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface ScreenHost {
|
||||
openUrl(url: string): void;
|
||||
quickSave(): void;
|
||||
quickLoad(): void;
|
||||
/** The Skip action: fast-forward through text already seen. */
|
||||
setSkipping?(on: boolean): void;
|
||||
/** Ask the player to confirm (Confirm action). */
|
||||
ask(text: string): Promise<boolean>;
|
||||
/** The name of the current save page (`FilePageNameInputValue`). */
|
||||
@@ -54,6 +56,8 @@ export interface ScreenHost {
|
||||
saveSlot(slot: string, confirm: boolean): void;
|
||||
loadSlot(slot: string): 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. */
|
||||
resolution?: [number, number];
|
||||
/** The page of the save/load screen (`persistent._file_page`). */
|
||||
@@ -174,6 +178,7 @@ export class ScreenManager {
|
||||
py: this.host.py,
|
||||
element: (value) => this.displayable(value),
|
||||
area: () => this.host.resolution ?? [1920, 1080],
|
||||
imageSize: (value) => this.host.imageSize?.(value) ?? null,
|
||||
finish: (value) => this.interactionReturn?.(value),
|
||||
}));
|
||||
}
|
||||
@@ -336,6 +341,8 @@ export class ScreenManager {
|
||||
this.pending = requestAnimationFrame(() => {
|
||||
this.pending = 0;
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -1147,6 +1154,25 @@ export class ScreenManager {
|
||||
}
|
||||
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
|
||||
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]);
|
||||
@@ -1546,11 +1572,16 @@ export class ScreenManager {
|
||||
const named = (value: any): AtSpec => {
|
||||
const name = value instanceof TransformRef ? value.name : typeof value === "string" ? value
|
||||
: 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 } : {};
|
||||
};
|
||||
const pick = (spec: AtSpec): AtSpec => (spec.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else)
|
||||
: spec.dyn ? named(this.ev(spec.dyn)) : spec);
|
||||
const specs: AtSpec[] = Array.isArray((props as any).at) ? (props as any).at.map(pick) : [];
|
||||
const pick = (spec: AtSpec): AtSpec[] => {
|
||||
if (spec.choose) return pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else);
|
||||
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 apply = (p: Props) => {
|
||||
const zoom = (p.zoom ?? 1);
|
||||
@@ -1562,6 +1593,11 @@ export class ScreenManager {
|
||||
if (p.rotate) parts.push(`rotate(${p.rotate}deg)`);
|
||||
el.style.transform = parts.join(" ");
|
||||
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);
|
||||
};
|
||||
for (const spec of specs) {
|
||||
@@ -1744,6 +1780,13 @@ export class ScreenManager {
|
||||
case "InvertSelected": this.perform(a, owner); return;
|
||||
case "UploadSync":
|
||||
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": {
|
||||
const yes = b, no = c;
|
||||
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",
|
||||
"ToggleFocus", "PauseAudio", "SetMixer", "FileAction", "FileSave", "FileLoad", "FileDelete", "FileTakeScreenshot",
|
||||
"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)
|
||||
const inputBase = new PyClass("InputValue", [OBJECT]);
|
||||
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.RenpyText = shape("text");
|
||||
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.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText`
|
||||
out.MatrixColor = shape("matrixcolor");
|
||||
|
||||
@@ -99,7 +99,7 @@ export function parseText(text: string): ParsedText {
|
||||
case "size":
|
||||
stack.push({ ...top, size: value, tag: name });
|
||||
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 });
|
||||
break;
|
||||
case "w":
|
||||
@@ -121,6 +121,10 @@ export function parseText(text: string): ParsedText {
|
||||
case "vspace":
|
||||
push("\n");
|
||||
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 "outlinecolor": case "rt": case "rb": case "art": case "shader":
|
||||
stack.push({ ...top, tag: name }); // styling we ignore but must still balance
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ImageDef {
|
||||
composite?: { pos: [number, number]; img: ImageDef }[];
|
||||
/** `AlphaMask(img, mask)`: the picture, visible where the mask is. */
|
||||
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;
|
||||
text?: string;
|
||||
textExpr?: Expr;
|
||||
@@ -110,6 +112,8 @@ export interface ScriptModule {
|
||||
export interface GameData {
|
||||
/** JSON files of the game (paths inside game/); their text is in `dataText` once loaded. */
|
||||
dataFiles?: string[];
|
||||
/** Natural size of each converted picture, by its name in the manifest. */
|
||||
imageSizes?: Record<string, [number, number]>;
|
||||
dataText?: Record<string, string>;
|
||||
build?: Record<string, any>;
|
||||
/** Natural image dimensions are divided by this factor after an asset replacement. */
|
||||
|
||||
+1
-1
@@ -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
|
||||
Vendored
+12
@@ -49,3 +49,15 @@ result_iter = iter([7, 8])
|
||||
result_first = next(result_iter)
|
||||
result_second = next(result_iter)
|
||||
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)
|
||||
@@ -8,6 +8,38 @@ const runtime = original ? JSON.parse(readFileSync(original, "utf8")) : null;
|
||||
const click = (element) => element.onclick({ stopPropagation() {}, preventDefault() {} });
|
||||
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 () => {
|
||||
const { engine, root } = makeEngine(runtime);
|
||||
try {
|
||||
|
||||
@@ -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_second, 8);
|
||||
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);
|
||||
});
|
||||
@@ -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"));
|
||||
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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user