feat: player runtime for goodluke and SaintlyCorruption
Text fields (`input`) with InputValue classes games can extend, continue/break in screen loops, Confirm, CaptureFocus/ClearFocus/InvertSelected, generator functions and iter/next, starred tuple items, renpy.file and json over data files loaded before the game starts, renpy.Container and register_sl_displayable, DiamondBox layout, ConditionSwitch/DynamicImage/Animation, renpy.show(what=...), callable transforms (`store.move_in(0.3)`), call_in_new_context as a call, im.matrix/im.Image/ MatrixColor, colorsys, fnmatch, FontGroup, persistent._clear and small renpy helpers. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e66160cffa
commit
e48521bdfb
12 files changed
+439
-25
No files matched your search
@@ -10,7 +10,7 @@ import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints }
|
|||||||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||||
import { naniText } from "./naninovel";
|
import { naniText } from "./naninovel";
|
||||||
import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens";
|
import { Displayable, ScreenManager, TransformRef, type ScreenHost } from "./screens";
|
||||||
import { displayableClass, makeRender, pygameNamespace } from "./render";
|
import { containerClass, displayableClass, makeRender, pygameNamespace } from "./render";
|
||||||
import { musicRoomClass } from "./musicroom";
|
import { musicRoomClass } from "./musicroom";
|
||||||
import { pythonModules } from "./pymodules";
|
import { pythonModules } from "./pymodules";
|
||||||
import { parseExpression, parseStatements } from "./pyparse";
|
import { parseExpression, parseStatements } from "./pyparse";
|
||||||
@@ -27,6 +27,26 @@ interface LabelRef {
|
|||||||
index: number;
|
index: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** JSON text parsed into Python values: objects become dicts. */
|
||||||
|
function fromJson(value: any): any {
|
||||||
|
if (Array.isArray(value)) return value.map(fromJson);
|
||||||
|
if (value && typeof value === "object") return new PyDict(Object.entries(value).map(([k, v]) => [k, fromJson(v)]));
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toJson(value: any): any {
|
||||||
|
if (value instanceof PyDict) return Object.fromEntries([...value.map.entries()].map(([k, v]) => [String(k), toJson(v)]));
|
||||||
|
if (Array.isArray(value)) return value.map(toJson);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `FontGroup().add(file, first, last)`: the browser picks fonts by letter itself, so the first file stands for the group. */
|
||||||
|
function fontGroup() {
|
||||||
|
const group = { files: [] as string[], add: (file: any, ..._range: any[]) => { group.files.push(pyStr(file)); return group; },
|
||||||
|
toString: () => group.files[0] ?? "" };
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
export class Engine {
|
export class Engine {
|
||||||
private liveImages?: LiveImages;
|
private liveImages?: LiveImages;
|
||||||
private imageScales = new Map<string, number>();
|
private imageScales = new Map<string, number>();
|
||||||
@@ -153,6 +173,7 @@ export class Engine {
|
|||||||
evalCond: (c) => truthy(this.safeEval(c)),
|
evalCond: (c) => truthy(this.safeEval(c)),
|
||||||
transform: (name) => game.transforms[name] ?? null,
|
transform: (name) => game.transforms[name] ?? null,
|
||||||
atl: (name) => game.atl?.[name] ?? null,
|
atl: (name) => game.atl?.[name] ?? null,
|
||||||
|
definitionOf: (value) => { try { return this.imageDefinition(value); } catch { return null; } },
|
||||||
evalExpr: (expr, frame) => {
|
evalExpr: (expr, frame) => {
|
||||||
const saved = this.py.locals;
|
const saved = this.py.locals;
|
||||||
this.py.locals = frame ? [...saved, frame] : saved;
|
this.py.locals = frame ? [...saved, frame] : saved;
|
||||||
@@ -284,6 +305,12 @@ 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 "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)
|
||||||
|
frames.push({ img: this.imageDefinition(value.args[i], depth + 1), d: typeof value.args[i + 1] === "number" ? value.args[i + 1] : undefined });
|
||||||
|
return { frames, repeat: true };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
throw new Error(`renpy.image: не поддерживается ${value.kind}`);
|
throw new Error(`renpy.image: не поддерживается ${value.kind}`);
|
||||||
}
|
}
|
||||||
@@ -344,7 +371,8 @@ export class Engine {
|
|||||||
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
|
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
|
||||||
const save = () => saveJSON(this.persistentKey, encodeValue(data));
|
const save = () => saveJSON(this.persistentKey, encodeValue(data));
|
||||||
return new Proxy(data, {
|
return new Proxy(data, {
|
||||||
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined : null),
|
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined
|
||||||
|
: k === "_clear" ? () => { for (const key of Object.keys(t)) delete t[key]; save(); return null; } : null),
|
||||||
set: (t, k: string, v) => {
|
set: (t, k: string, v) => {
|
||||||
t[k] = v;
|
t[k] = v;
|
||||||
save();
|
save();
|
||||||
@@ -460,7 +488,24 @@ 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,
|
||||||
|
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,
|
||||||
|
// a label run "in a new context" behaves like a call: it returns where it was started
|
||||||
|
call_in_new_context: (label: string) => { throw new JumpSignal("call", label); },
|
||||||
|
context: () => ({ _menu: false }),
|
||||||
|
get_focus_rect: (id: any) => this.screens.focusRect(pyStr(id)),
|
||||||
|
in_rollback: () => false,
|
||||||
|
is_in_test: () => false,
|
||||||
|
retain_after_load: () => null,
|
||||||
|
context_nesting_level: () => 0,
|
||||||
|
current_screen: () => null,
|
||||||
|
substitutions: { substitute: (text: any) => this.interp(pyStr(text)) },
|
||||||
|
set_screen_variable: (name: any, value: any) => { this.screens.setScreenVariable(pyStr(name), value); return null; },
|
||||||
Displayable: this.displayableBase,
|
Displayable: this.displayableBase,
|
||||||
|
Container: containerClass(this.displayableBase, () => this.py),
|
||||||
|
// `renpy.register_sl_displayable("name", Class, "fixed").add_property("x")`: the player knows these widgets already
|
||||||
|
register_sl_displayable: () => { const chain: any = { add_property: () => chain, add_style_property: () => chain, add_prefix_style_property: () => chain, add_pattern: () => chain }; return chain; },
|
||||||
Render: (w: number, h: number) => makeRender(Number(w), Number(h)),
|
Render: (w: number, h: number) => makeRender(Number(w), Number(h)),
|
||||||
render: (d: any, w: number, h: number, st = 0, at = 0) => this.screens.renderHost.renderOf(d, Number(w), Number(h), st, at),
|
render: (d: any, w: number, h: number, st = 0, at = 0) => this.screens.renderHost.renderOf(d, Number(w), Number(h), st, at),
|
||||||
redraw: (d: any, delay = 0) => { this.screens.renderHost.requestRedraw(d, Number(delay)); return null; },
|
redraw: (d: any, delay = 0) => { this.screens.renderHost.requestRedraw(d, Number(delay)); return null; },
|
||||||
@@ -469,7 +514,7 @@ 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: { draw: { kill_textures: () => null }, render: { free_memory: () => null } },
|
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], {}) } },
|
||||||
jump_out_of_context: (label: string) => {
|
jump_out_of_context: (label: string) => {
|
||||||
throw new JumpSignal("jump", label);
|
throw new JumpSignal("jump", label);
|
||||||
},
|
},
|
||||||
@@ -483,7 +528,8 @@ export class Engine {
|
|||||||
if (!probe || !probe.complete || !probe.naturalWidth) throw new Error(`IOError: размер ${text} недоступен`);
|
if (!probe || !probe.complete || !probe.naturalWidth) throw new Error(`IOError: размер ${text} недоступен`);
|
||||||
return [probe.naturalWidth, probe.naturalHeight];
|
return [probe.naturalWidth, probe.naturalHeight];
|
||||||
},
|
},
|
||||||
music: music("music"),
|
music: Object.assign(music("music"), { register_channel: () => null, get_loop: () => [],
|
||||||
|
get_channel: () => ({ get_context: () => ({ last_relative_volume: 1 }) }) }),
|
||||||
sound: music("sound"),
|
sound: music("sound"),
|
||||||
play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}),
|
play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}),
|
||||||
notify: (msg: any) => this.notify(pyStr(msg)),
|
notify: (msg: any) => this.notify(pyStr(msg)),
|
||||||
@@ -506,9 +552,13 @@ export class Engine {
|
|||||||
show: (name: any, ...a: any[]) => {
|
show: (name: any, ...a: any[]) => {
|
||||||
const kw = kwOf(a);
|
const kw = kwOf(a);
|
||||||
const parts = String(name).split(" ");
|
const parts = String(name).split(" ");
|
||||||
|
// `renpy.show("tag", what=displayable)`: the displayable is what the tag shows
|
||||||
|
if (kw.what !== undefined && kw.what !== null) this.registerImage(parts[0], this.imageDefinition(kw.what));
|
||||||
const picture = this.resolveImage(parts);
|
const picture = this.resolveImage(parts);
|
||||||
if (!picture) return null;
|
if (!picture) return null;
|
||||||
const at = ((kw.at_list ?? []) as any[]).filter((t) => t && t.__transform).map((t) => ({ name: t.__transform }));
|
const at = ((kw.at_list ?? []) as any[]).filter((t) => t && t.__transform).map((t) => ({
|
||||||
|
name: t.__transform, ...(this.game.atl?.[t.__transform] ? { atl: t.__transform } : {}),
|
||||||
|
...(t.args?.length ? { args: t.args.map((v: any) => ({ k: "const", v })) } : {}) }));
|
||||||
this.renderer.show(picture, kw.tag ?? parts[0], at, { zorder: kw.zorder ?? undefined, behind: kw.behind ?? undefined });
|
this.renderer.show(picture, kw.tag ?? parts[0], at, { zorder: kw.zorder ?? undefined, behind: kw.behind ?? undefined });
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
@@ -553,7 +603,13 @@ export class Engine {
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
|
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
|
||||||
loadable: (path: any) => this.fileUrl(String(path)) !== null,
|
loadable: (path: any) => this.fileUrl(String(path)) !== null || this.game.dataText?.[String(path).toLowerCase()] !== undefined,
|
||||||
|
file: (path: any) => {
|
||||||
|
const text = this.game.dataText?.[String(path).replaceAll("\\", "/").toLowerCase()];
|
||||||
|
if (text === undefined) throw new Error(`IOError: файл ${path} недоступен`);
|
||||||
|
return { read: () => text, readlines: () => text.split(/(?<=\n)/), close: () => null };
|
||||||
|
},
|
||||||
|
open_file: (path: any) => this.builtins.renpy.file(path),
|
||||||
is_init_phase: () => false,
|
is_init_phase: () => false,
|
||||||
call_stack_depth: () => this.stack.length,
|
call_stack_depth: () => this.stack.length,
|
||||||
const: (value: any) => value,
|
const: (value: any) => value,
|
||||||
@@ -614,7 +670,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(),
|
||||||
gui: {},
|
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) }),
|
||||||
audio: this.game.audio,
|
audio: this.game.audio,
|
||||||
@@ -627,7 +683,7 @@ export class Engine {
|
|||||||
log10: Math.log10, fmod: (a: number, b: number) => a % b,
|
log10: Math.log10, fmod: (a: number, b: number) => a % b,
|
||||||
modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)],
|
modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)],
|
||||||
degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180,
|
degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180,
|
||||||
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) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
|
||||||
@@ -679,13 +735,20 @@ export class Engine {
|
|||||||
set: (xs: any = []) => [...new Set(xs)],
|
set: (xs: any = []) => [...new Set(xs)],
|
||||||
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(),
|
||||||
|
json: {
|
||||||
|
loads: (text: any) => fromJson(JSON.parse(pyStr(text))),
|
||||||
|
dumps: (value: any) => JSON.stringify(toJson(value)),
|
||||||
|
},
|
||||||
|
loads: (text: any) => fromJson(JSON.parse(pyStr(text))),
|
||||||
|
dumps: (value: any) => JSON.stringify(toJson(value)),
|
||||||
pygame: pygameNamespace,
|
pygame: pygameNamespace,
|
||||||
dir: (o?: any) => (o === undefined ? Object.keys(this.store) : Object.keys(o ?? {})),
|
dir: (o?: any) => (o === undefined ? Object.keys(this.store) : Object.keys(o ?? {})),
|
||||||
dict: (...a: any[]) => {
|
dict: Object.assign((...a: any[]) => {
|
||||||
const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []);
|
const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []);
|
||||||
for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v);
|
for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v);
|
||||||
return d;
|
return d;
|
||||||
},
|
}, { __statics: { fromkeys: (keys: any, value: any = null) => new PyDict([...keys].map((k: any) => [k, value])) } }),
|
||||||
sorted: (xs: any[]) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
sorted: (xs: any[]) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
||||||
reversed: (xs: any[]) => [...xs].reverse(),
|
reversed: (xs: any[]) => [...xs].reverse(),
|
||||||
enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]),
|
enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]),
|
||||||
@@ -746,7 +809,8 @@ export class Engine {
|
|||||||
for (const name of [...Object.keys(this.game.transforms), ...Object.keys(this.game.atl ?? {}),
|
for (const name of [...Object.keys(this.game.transforms), ...Object.keys(this.game.atl ?? {}),
|
||||||
"left", "right", "center", "truecenter", "topleft", "topright", "top", "default", "reset", "offscreenleft",
|
"left", "right", "center", "truecenter", "topleft", "topright", "top", "default", "reset", "offscreenleft",
|
||||||
"offscreenright"]) {
|
"offscreenright"]) {
|
||||||
if (!(name in out)) out[name] = { __transform: name };
|
// callable too: `store.move_in(0.3)` is the same transform with other arguments
|
||||||
|
if (!(name in out)) out[name] = Object.assign((...args: any[]) => ({ __transform: name, args }), { __transform: name });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -965,6 +1029,9 @@ 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),
|
||||||
|
ask: (text) => this.confirmAsk(text, "OK"),
|
||||||
|
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"); },
|
get filePage() { return String(engine.persistent._file_page ?? "1"); },
|
||||||
set filePage(page: string) { engine.persistent._file_page = page; engine.screens.refresh(); },
|
set filePage(page: string) { engine.persistent._file_page = page; engine.screens.refresh(); },
|
||||||
transform: (name) => this.game.transforms[name] ?? null,
|
transform: (name) => this.game.transforms[name] ?? null,
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
|
|||||||
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
|
||||||
loadTranslator(game, manifest, lang),
|
loadTranslator(game, manifest, lang),
|
||||||
]);
|
]);
|
||||||
|
// files the game reads while it starts (`renpy.file(...)`) have to be here before its first line runs
|
||||||
|
if (game.dataFiles?.length) {
|
||||||
|
const texts = await Promise.all(game.dataFiles.map((path) =>
|
||||||
|
fetch(blob(manifest, `data/${path}`), { credentials: "same-origin" }).then((r) => (r.ok ? r.text() : ""))));
|
||||||
|
game.dataText = Object.fromEntries(game.dataFiles.map((path, i) => [path.toLowerCase(), texts[i]]));
|
||||||
|
}
|
||||||
const cloud = await cloudActive();
|
const cloud = await cloudActive();
|
||||||
if (game.runtime) {
|
if (game.runtime) {
|
||||||
const load = RUNTIMES[game.runtime];
|
const load = RUNTIMES[game.runtime];
|
||||||
|
|||||||
@@ -312,6 +312,38 @@ export function pythonModules(): Record<string, any> {
|
|||||||
OrderedDict: () => new PyDict(),
|
OrderedDict: () => new PyDict(),
|
||||||
},
|
},
|
||||||
functools: functionModules,
|
functools: functionModules,
|
||||||
|
colorsys: {
|
||||||
|
rgb_to_hls: (r: number, g: number, b: number) => {
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;
|
||||||
|
if (d === 0) return [0, l, 0];
|
||||||
|
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
const h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||||
|
return [((h / 6) % 1 + 1) % 1, l, s];
|
||||||
|
},
|
||||||
|
hls_to_rgb: (h: number, l: number, s: number) => {
|
||||||
|
if (s === 0) return [l, l, l];
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s, p = 2 * l - q;
|
||||||
|
const channel = (t: number) => {
|
||||||
|
t = ((t % 1) + 1) % 1;
|
||||||
|
return t < 1 / 6 ? p + (q - p) * 6 * t : t < 1 / 2 ? q : t < 2 / 3 ? p + (q - p) * (2 / 3 - t) * 6 : p;
|
||||||
|
};
|
||||||
|
return [channel(h + 1 / 3), channel(h), channel(h - 1 / 3)];
|
||||||
|
},
|
||||||
|
rgb_to_hsv: (r: number, g: number, b: number) => {
|
||||||
|
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
|
||||||
|
const h = d === 0 ? 0 : max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
||||||
|
return [((h / 6) % 1 + 1) % 1, max === 0 ? 0 : d / max, max];
|
||||||
|
},
|
||||||
|
hsv_to_rgb: (h: number, s: number, v: number) => {
|
||||||
|
const i = Math.floor(h * 6), f = h * 6 - i, p = v * (1 - s), q = v * (1 - f * s), t = v * (1 - (1 - f) * s);
|
||||||
|
return [[v, q, p, p, t, v][i % 6], [t, v, v, q, p, p][i % 6], [p, p, t, v, v, q][i % 6]];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fnmatch: {
|
||||||
|
fnmatch: (name: any, pattern: any) => new RegExp("^" + String(pattern).replace(/[.+^${}()|\\]/g, "\\$&")
|
||||||
|
.replace(/\*/g, ".*").replace(/\?/g, ".") + "$", "i").test(String(name)),
|
||||||
|
},
|
||||||
|
traceback: { format_exc: () => "", print_exc: () => null },
|
||||||
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),
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ class PyException extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FnDef = { name: string; params: any[]; body?: PyStmt[]; expr?: Expr };
|
type FnDef = { name: string; params: any[]; body?: PyStmt[]; expr?: Expr; gen?: boolean };
|
||||||
type FnCtx = {
|
type FnCtx = {
|
||||||
vars: Map<string, any>;
|
vars: Map<string, any>;
|
||||||
globals: Set<string>;
|
globals: Set<string>;
|
||||||
@@ -493,6 +493,7 @@ function methodOf(obj: any, name: string): any {
|
|||||||
}
|
}
|
||||||
if (typeof obj === "string") {
|
if (typeof obj === "string") {
|
||||||
const m: Record<string, Builtin> = {
|
const m: Record<string, Builtin> = {
|
||||||
|
encode: () => [...new TextEncoder().encode(obj)], // bytes are lists of numbers here
|
||||||
lower: () => obj.toLowerCase(),
|
lower: () => obj.toLowerCase(),
|
||||||
upper: () => obj.toUpperCase(),
|
upper: () => obj.toUpperCase(),
|
||||||
capitalize: () => obj.charAt(0).toUpperCase() + obj.slice(1).toLowerCase(),
|
capitalize: () => obj.charAt(0).toUpperCase() + obj.slice(1).toLowerCase(),
|
||||||
@@ -605,6 +606,7 @@ export class Python {
|
|||||||
if (v === undefined) this.warnOnce(`у ${o instanceof PyInstance ? o.cls.name : "класса"} нет атрибута ${e.a}`);
|
if (v === undefined) this.warnOnce(`у ${o instanceof PyInstance ? o.cls.name : "класса"} нет атрибута ${e.a}`);
|
||||||
return v === undefined ? null : v;
|
return v === undefined ? null : v;
|
||||||
}
|
}
|
||||||
|
if (typeof o === "function" && Object.hasOwn((o as any).__statics ?? {}, e.a)) return (o as any).__statics[e.a]; // dict.fromkeys
|
||||||
if (!safeProperty(e.a) || typeof o === "function") return null;
|
if (!safeProperty(e.a) || typeof o === "function") return null;
|
||||||
if (o === null || o === undefined) {
|
if (o === null || o === undefined) {
|
||||||
if (this.strict) this.raise("AttributeError", `у None нет атрибута ${e.a}`);
|
if (this.strict) this.raise("AttributeError", `у None нет атрибута ${e.a}`);
|
||||||
@@ -678,7 +680,7 @@ export class Python {
|
|||||||
return truthy(this.eval(e.c)) ? this.eval(e.t) : this.eval(e.e);
|
return truthy(this.eval(e.c)) ? this.eval(e.t) : this.eval(e.e);
|
||||||
case "list":
|
case "list":
|
||||||
case "tuple":
|
case "tuple":
|
||||||
return e.v.map((x: Expr) => this.eval(x));
|
return e.v.flatMap((x: Expr) => (x.k === "star" ? [...iterate(this.eval(x.v))] : [this.eval(x)]));
|
||||||
case "dict":
|
case "dict":
|
||||||
{
|
{
|
||||||
const out = new PyDict();
|
const out = new PyDict();
|
||||||
@@ -870,6 +872,8 @@ 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 "yield_from": this.yielded.at(-1)?.push(...iterate(this.eval(s.v))); 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);
|
||||||
@@ -1033,19 +1037,24 @@ export class Python {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the generator functions that are running have yielded so far (they run to the end, see PyIterator). */
|
||||||
|
private yielded: any[][] = [];
|
||||||
|
|
||||||
private callUser(fn: any, given: any[]): any {
|
private callUser(fn: any, given: any[]): any {
|
||||||
const def: FnDef = fn.__def;
|
const def: FnDef = fn.__def;
|
||||||
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) return err.value;
|
if (!(err instanceof ReturnSignal)) throw err;
|
||||||
throw err;
|
if (!def.gen) return err.value;
|
||||||
}
|
}
|
||||||
return null;
|
return def.gen ? new PyIterator(this.yielded.at(-1) ?? []) : null;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (def.gen) this.yielded.pop();
|
||||||
leave();
|
leave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1233,7 +1242,10 @@ export class Python {
|
|||||||
case "list":
|
case "list":
|
||||||
case "tuple": {
|
case "tuple": {
|
||||||
const out: any[] = [];
|
const out: any[] = [];
|
||||||
for (const part of e.v as Expr[]) out.push(yield* this.evalG(part));
|
for (const part of e.v as Expr[]) {
|
||||||
|
if (part.k === "star") out.push(...iterate(yield* this.evalG(part.v)));
|
||||||
|
else out.push(yield* this.evalG(part));
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
case "dict": {
|
case "dict": {
|
||||||
@@ -1334,7 +1346,7 @@ export class Python {
|
|||||||
|
|
||||||
private defineFunction(s: PyStmt) {
|
private defineFunction(s: PyStmt) {
|
||||||
const closure = this.fn?.isClassBody ? this.locals.slice(0, this.fn.index) : this.locals.slice();
|
const closure = this.fn?.isClassBody ? this.locals.slice(0, this.fn.index) : this.locals.slice();
|
||||||
let value: any = this.makeFunction({ name: s.name, params: s.params, body: s.body }, closure,
|
let value: any = this.makeFunction({ name: s.name, params: s.params, body: s.body, gen: s.gen }, closure,
|
||||||
s.params.map((p: any) => (p.d ? this.eval(p.d) : undefined)));
|
s.params.map((p: any) => (p.d ? this.eval(p.d) : undefined)));
|
||||||
for (const deco of [...(s.decos ?? [])].reverse()) {
|
for (const deco of [...(s.decos ?? [])].reverse()) {
|
||||||
if (deco.k === "name") {
|
if (deco.k === "name") {
|
||||||
@@ -1476,6 +1488,18 @@ export function decodeValue(v: any): any {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Builtins that need the interpreter: type checks, attribute access, callbacks. */
|
/** Builtins that need the interpreter: type checks, attribute access, callbacks. */
|
||||||
|
/** `iter(x)`: a position in a sequence that `next` moves along. */
|
||||||
|
export class PyIterator {
|
||||||
|
private at = 0;
|
||||||
|
constructor(private items: any[]) {}
|
||||||
|
next(fallback?: any, hasFallback = false) {
|
||||||
|
if (this.at < this.items.length) return this.items[this.at++];
|
||||||
|
if (hasFallback) return fallback;
|
||||||
|
throw new Error("StopIteration");
|
||||||
|
}
|
||||||
|
*[Symbol.iterator]() { while (this.at < this.items.length) yield this.items[this.at++]; }
|
||||||
|
}
|
||||||
|
|
||||||
const identities = new WeakMap<object, number>();
|
const identities = new WeakMap<object, number>();
|
||||||
let lastIdentity = 0;
|
let lastIdentity = 0;
|
||||||
|
|
||||||
@@ -1539,7 +1563,15 @@ export function userBuiltins(py: Python): Record<string, any> {
|
|||||||
if (!safePythonProperty(o, name)) throw new Error("Служебный атрибут недоступен");
|
if (!safePythonProperty(o, name)) throw new Error("Служебный атрибут недоступен");
|
||||||
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
py.bind({ k: "attr", o: { k: "const", v: o }, a: name }, value);
|
||||||
},
|
},
|
||||||
iter: (x: any) => [...iterate(x)],
|
iter: (x: any) => new PyIterator([...iterate(x)]),
|
||||||
|
next: (it: any, ...rest: any[]) => {
|
||||||
|
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", "");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
repr: (x: any) => pyRepr(x),
|
||||||
type: (x: any) => {
|
type: (x: any) => {
|
||||||
if (x instanceof PyInstance) return x.cls;
|
if (x instanceof PyInstance) return x.cls;
|
||||||
if (x instanceof PyDict) return typeOf("dict");
|
if (x instanceof PyDict) return typeOf("dict");
|
||||||
|
|||||||
@@ -96,6 +96,25 @@ export function displayableClass(): PyClass {
|
|||||||
return registerNativeClass(base);
|
return registerNativeClass(base);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `renpy.Container`: a displayable with children; by default they are all drawn at the top left corner. */
|
||||||
|
export function containerClass(base: PyClass, py: () => Python): PyClass {
|
||||||
|
const container = new PyClass("Container", [base]);
|
||||||
|
const method = (name: string, fn: (self: PyInstance, ...args: any[]) => any) => container.attrs.set(name, nativeMethod(fn));
|
||||||
|
method("__init__", (self, ..._rest: any[]) => { self.attrs.children = self.attrs.children ?? []; return null; });
|
||||||
|
method("add", (self, child: any) => { (self.attrs.children ??= []).push(child); return null; });
|
||||||
|
method("remove", (self, child: any) => { self.attrs.children = (self.attrs.children ?? []).filter((c: any) => c !== child); return null; });
|
||||||
|
method("_list_type", () => []);
|
||||||
|
method("render", (self, w: number, h: number, st: number, at: number) => {
|
||||||
|
const render = makeRender(w, h);
|
||||||
|
for (const child of self.attrs.children ?? []) {
|
||||||
|
const draw = py().attrOf(self, "render_child");
|
||||||
|
render.blit(draw ? draw(child, w, h, st, at) : child, [0, 0]);
|
||||||
|
}
|
||||||
|
return render;
|
||||||
|
});
|
||||||
|
return registerNativeClass(container);
|
||||||
|
}
|
||||||
|
|
||||||
export const isDisplayableInstance = (v: any): v is PyInstance => v instanceof PyInstance && Boolean(v.cls.find("render"));
|
export const isDisplayableInstance = (v: any): v is PyInstance => v instanceof PyInstance && Boolean(v.cls.find("render"));
|
||||||
|
|
||||||
/** pygame names that displayable code compares event types and keys with. */
|
/** pygame names that displayable code compares event types and keys with. */
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export interface RendererHost {
|
|||||||
atl(name: string): AtlBlock | null;
|
atl(name: string): AtlBlock | null;
|
||||||
/** Evaluate an expression, optionally with extra variables (ATL parameters) in scope. */
|
/** Evaluate an expression, optionally with extra variables (ATL parameters) in scope. */
|
||||||
evalExpr(expr: any, frame?: Map<string, any>): any;
|
evalExpr(expr: any, frame?: Map<string, any>): any;
|
||||||
|
/** The picture a displayable value (Animation, Solid, ...) stands for. */
|
||||||
|
definitionOf?(value: any): ImageDef | null;
|
||||||
text(e: any): string;
|
text(e: any): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +158,8 @@ export class Renderer {
|
|||||||
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
|
const color = /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value } : null;
|
||||||
return this.build(ref ?? color ?? { file: value }, depth + 1);
|
return this.build(ref ?? color ?? { file: value }, depth + 1);
|
||||||
}
|
}
|
||||||
return document.createElement("div");
|
const made = this.host.definitionOf?.(value);
|
||||||
|
return made ? this.build(made, depth + 1) : document.createElement("div");
|
||||||
}
|
}
|
||||||
if (def.switch) {
|
if (def.switch) {
|
||||||
const hit = def.switch.find(([c]) => this.host.evalCond(c));
|
const hit = def.switch.find(([c]) => this.host.evalCond(c));
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { parseExpression } from "./pyparse";
|
|||||||
import { movieDisplayable } from "./movie";
|
import { movieDisplayable } from "./movie";
|
||||||
import { RenderHost, isDisplayableInstance } from "./render";
|
import { RenderHost, isDisplayableInstance } from "./render";
|
||||||
import { strftime } from "./pymodules";
|
import { strftime } from "./pymodules";
|
||||||
import { PyDict, PyInstance, callableOf, pyEq, pyStr, truthy, type Python } from "./python";
|
import { OBJECT, PyClass, PyDict, PyInstance, callableOf, nativeMethod, pyEq, pyStr, registerNativeClass, truthy, type Python } from "./python";
|
||||||
import type { AtSpec, Expr } from "./types";
|
import type { AtSpec, Expr } from "./types";
|
||||||
|
|
||||||
export interface ScreenDef {
|
export interface ScreenDef {
|
||||||
@@ -45,6 +45,10 @@ export interface ScreenHost {
|
|||||||
openUrl(url: string): void;
|
openUrl(url: string): void;
|
||||||
quickSave(): void;
|
quickSave(): void;
|
||||||
quickLoad(): void;
|
quickLoad(): void;
|
||||||
|
/** Ask the player to confirm (Confirm action). */
|
||||||
|
ask(text: string): Promise<boolean>;
|
||||||
|
/** The name of the current save page (`FilePageNameInputValue`). */
|
||||||
|
filePageName: string;
|
||||||
/** Slot metadata for File* actions; null when empty or not yet read. */
|
/** Slot metadata for File* actions; null when empty or not yet read. */
|
||||||
slotInfo(slot: string): { title: string; time: string } | null;
|
slotInfo(slot: string): { title: string; time: string } | null;
|
||||||
saveSlot(slot: string, confirm: boolean): void;
|
saveSlot(slot: string, confirm: boolean): void;
|
||||||
@@ -69,6 +73,11 @@ type Props = Record<string, any>;
|
|||||||
|
|
||||||
class ActionAbort extends Error {}
|
class ActionAbort extends Error {}
|
||||||
|
|
||||||
|
/** `continue` and `break` in a screen's `for`. */
|
||||||
|
class LoopSignal {
|
||||||
|
constructor(public kind: "continue" | "break") {}
|
||||||
|
}
|
||||||
|
|
||||||
/** A transform used as a value: `{"at": move_in}`; `at icon["at"]` then applies it. */
|
/** A transform used as a value: `{"at": move_in}`; `at icon["at"]` then applies it. */
|
||||||
export class TransformRef {
|
export class TransformRef {
|
||||||
constructor(public name: string) {}
|
constructor(public name: string) {}
|
||||||
@@ -82,6 +91,14 @@ export class ScreenAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** A displayable built by game code: Crop(...), Solid(...), Frame(...), Transform(...). */
|
/** A displayable built by game code: Crop(...), Solid(...), Frame(...), Transform(...). */
|
||||||
|
/** `im.matrix(20 or 25 numbers)`: rows of [r, g, b, a, offset]. */
|
||||||
|
const legacyMatrix = (...given: any[]) => {
|
||||||
|
const values: number[] = (given.length === 1 && Array.isArray(given[0]) ? given[0] : given).flat();
|
||||||
|
if (values.length !== 20 && values.length !== 25) throw new Error("im.matrix: нужны 20 или 25 чисел");
|
||||||
|
const rows = [0, 1, 2, 3].map((i) => values.slice(i * 5, i * 5 + 5) as [number, number, number, number, number]);
|
||||||
|
return new ColorMatrix([...rows, [0, 0, 0, 0, 1]]);
|
||||||
|
};
|
||||||
|
|
||||||
export class Displayable {
|
export class Displayable {
|
||||||
constructor(public kind: string, public args: any[], public kw: Props) {}
|
constructor(public kind: string, public args: any[], public kw: Props) {}
|
||||||
}
|
}
|
||||||
@@ -189,6 +206,26 @@ export class ScreenManager {
|
|||||||
return name in this.screens;
|
return name in this.screens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
captureFocus(id: string) {
|
||||||
|
this.focus = { id, rect: this.screenRect(this.hovered ?? this.activated) };
|
||||||
|
}
|
||||||
|
|
||||||
|
clearFocus(id: string) {
|
||||||
|
if (this.focus?.id === id) this.focus = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `renpy.get_focus_rect(id)`: where the widget that captured the focus `id` is. */
|
||||||
|
focusRect(id: string) {
|
||||||
|
return this.focus?.id === id ? this.focus.rect : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `renpy.set_screen_variable`: set a variable of the screen being drawn (or the newest shown one). */
|
||||||
|
setScreenVariable(name: string, value: any) {
|
||||||
|
const target = this.rendering ?? [...this.shown.values()].pop();
|
||||||
|
target?.vars.set(name, value);
|
||||||
|
this.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
/** ui.interact waits on Return from an already shown screen, without hiding it. */
|
/** ui.interact waits on Return from an already shown screen, without hiding it. */
|
||||||
interact(done: (value: any) => void): () => void {
|
interact(done: (value: any) => void): () => void {
|
||||||
if (this.interactionReturn) throw new Error("Уже выполняется ui.interact");
|
if (this.interactionReturn) throw new Error("Уже выполняется ui.interact");
|
||||||
@@ -425,11 +462,18 @@ export class ScreenManager {
|
|||||||
try {
|
try {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
py.bind(node.t, item, frame);
|
py.bind(node.t, item, frame);
|
||||||
|
try {
|
||||||
this.nodes(node.body, ctx, out);
|
this.nodes(node.body, ctx, out);
|
||||||
|
} catch (signal) {
|
||||||
|
if (!(signal instanceof LoopSignal)) throw signal;
|
||||||
|
if (signal.kind === "break") break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
py.locals = py.locals.slice(0, -1);
|
py.locals = py.locals.slice(0, -1);
|
||||||
}
|
}
|
||||||
|
} else if (node.c === "continue" || node.c === "break") {
|
||||||
|
throw new LoopSignal(node.c);
|
||||||
} else if (node.c === "use") {
|
} else if (node.c === "use") {
|
||||||
this.use(node, ctx, out);
|
this.use(node, ctx, out);
|
||||||
} else if (node.c === "python") {
|
} else if (node.c === "python") {
|
||||||
@@ -591,6 +635,7 @@ export class ScreenManager {
|
|||||||
switch (node.w) {
|
switch (node.w) {
|
||||||
case "fixed": return this.container(node, ctx, "div", "abs", ["fixed"], true);
|
case "fixed": return this.container(node, ctx, "div", "abs", ["fixed"], true);
|
||||||
case "drag": return this.dragBox(node, ctx);
|
case "drag": return this.dragBox(node, ctx);
|
||||||
|
case "diamondbox": return this.diamondBox(node, ctx);
|
||||||
case "frame":
|
case "frame":
|
||||||
case "window": return this.container(node, ctx, "div", "stack", [node.w, "frame"]);
|
case "window": return this.container(node, ctx, "div", "stack", [node.w, "frame"]);
|
||||||
case "vbox": return this.container(node, ctx, "div", "col", ["vbox"]);
|
case "vbox": return this.container(node, ctx, "div", "col", ["vbox"]);
|
||||||
@@ -608,12 +653,35 @@ export class ScreenManager {
|
|||||||
case "add": return this.add(node, ctx);
|
case "add": return this.add(node, ctx);
|
||||||
case "null": return this.nullWidget(node, ctx);
|
case "null": return this.nullWidget(node, ctx);
|
||||||
case "mousearea": return this.mouseArea(node, ctx);
|
case "mousearea": return this.mouseArea(node, ctx);
|
||||||
|
case "input": return this.inputWidget(node, ctx);
|
||||||
case "bar":
|
case "bar":
|
||||||
case "vbar": return this.bar(node, ctx);
|
case "vbar": return this.bar(node, ctx);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Children in a zigzag chain: every second one sits half a cell lower (the game's DiamondBox). */
|
||||||
|
private diamondBox(node: any, ctx: Ctx): HTMLElement {
|
||||||
|
const el = this.container(node, ctx, "div", "abs", ["fixed"]);
|
||||||
|
const props = this.resolve(node, ctx, ["fixed"]);
|
||||||
|
const cell = Math.max(1, Math.round(num(this.pick(props, "cell_size")) ?? 180));
|
||||||
|
const step = (cell + (num(this.pick(props, "spacing")) ?? 0)) / 2;
|
||||||
|
const fit = this.pick(props, "fit") ?? "fill";
|
||||||
|
const lower = truthy(this.pick(props, "start_lower"));
|
||||||
|
const children = [...el.children] as HTMLElement[];
|
||||||
|
children.forEach((child, index) => {
|
||||||
|
const down = children.length > 1 && (lower ? index % 2 === 0 : index % 2 === 1);
|
||||||
|
child.style.position = "absolute";
|
||||||
|
child.style.left = `${Math.round(index * step)}px`;
|
||||||
|
child.style.top = `${down ? Math.round(step) : 0}px`;
|
||||||
|
child.style.width = child.style.height = `${cell}px`;
|
||||||
|
child.style.objectFit = fit === "scale-up" ? "cover" : String(fit);
|
||||||
|
});
|
||||||
|
el.style.width = `${Math.ceil(cell + Math.max(0, children.length - 1) * step)}px`;
|
||||||
|
el.style.height = `${Math.ceil(cell + (children.length > 1 ? step : 0))}px`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
/** A box the player can move with the pointer; where it was left is kept while the screen is shown. */
|
/** A box the player can move with the pointer; where it was left is kept while the screen is shown. */
|
||||||
private dragBox(node: any, ctx: Ctx): HTMLElement {
|
private dragBox(node: any, ctx: Ctx): HTMLElement {
|
||||||
const el = this.container(node, ctx, "div", "abs", ["fixed"]);
|
const el = this.container(node, ctx, "div", "abs", ["fixed"]);
|
||||||
@@ -720,6 +788,8 @@ export class ScreenManager {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private hovered: HTMLElement | null = null;
|
||||||
|
|
||||||
/** The widget whose `ToggleFocus` opened a popup, and where it is (in screen coordinates). */
|
/** The widget whose `ToggleFocus` opened a popup, and where it is (in screen coordinates). */
|
||||||
private focus: { id: string; rect: { x: number; y: number; w: number; h: number } } | null = null;
|
private focus: { id: string; rect: { x: number; y: number; w: number; h: number } } | null = null;
|
||||||
private activated: HTMLElement | null = null;
|
private activated: HTMLElement | null = null;
|
||||||
@@ -785,6 +855,58 @@ export class ScreenManager {
|
|||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `input`: a text field bound to an InputValue (or standing alone, with `default`). */
|
||||||
|
private inputWidget(node: any, ctx: Ctx): HTMLElement {
|
||||||
|
const props = this.resolve(node, ctx, ["input", "text"]);
|
||||||
|
const value = props.value ? this.ev(props.value) : null;
|
||||||
|
const multiline = truthy(this.pick(props, "multiline"));
|
||||||
|
const el = document.createElement(multiline ? "textarea" : "input") as HTMLInputElement;
|
||||||
|
el.className = "pg-sw pg-sinput";
|
||||||
|
this.layout(el, props, ctx, { fill: false });
|
||||||
|
this.textLook(el, props, "idle");
|
||||||
|
const own = ctx.inst.vars;
|
||||||
|
const key = `__input:${node.props.id ? pyStr(this.ev(node.props.id)) : ""}`;
|
||||||
|
const field = value instanceof PyInstance ? value : null;
|
||||||
|
const read = (): string => pyStr(field ? this.host.py.attrOf(field, "get_text")() ?? "" : "");
|
||||||
|
const start = field ? read() : own.has(key) ? own.get(key) : pyStr(this.pick(props, "default") ?? "");
|
||||||
|
el.value = pyStr(start);
|
||||||
|
const length = num(this.pick(props, "length"));
|
||||||
|
if (length) el.maxLength = length;
|
||||||
|
if (this.pick(props, "mask") != null) el.type = "password";
|
||||||
|
const allow = this.pick(props, "allow"), exclude = this.pick(props, "exclude");
|
||||||
|
const clean = (text: string) => [...text].filter((ch) => (allow ? String(allow).includes(ch) : true)
|
||||||
|
&& (exclude ? !String(exclude).includes(ch) : true)).join("");
|
||||||
|
el.oninput = () => {
|
||||||
|
const text = clean(el.value);
|
||||||
|
if (text !== el.value) el.value = text;
|
||||||
|
if (field) { this.host.py.attrOf(field, "set_text")(text); this.refresh(); }
|
||||||
|
else own.set(key, text);
|
||||||
|
if (props.changed) this.run(this.callWith(this.ev(props.changed), text), ctx.inst);
|
||||||
|
};
|
||||||
|
el.onkeydown = (ev) => {
|
||||||
|
ev.stopPropagation();
|
||||||
|
if ((ev as KeyboardEvent).key !== "Enter" || multiline) return;
|
||||||
|
const text = el.value;
|
||||||
|
if (field) {
|
||||||
|
const answer = this.host.py.attrOf(field, "enter")();
|
||||||
|
field.attrs.editing = false;
|
||||||
|
if (answer !== null && answer !== undefined) this.run(answer, ctx.inst);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ctx.inst.onReturn) ctx.inst.onReturn(text);
|
||||||
|
else this.interactionReturn?.(text);
|
||||||
|
};
|
||||||
|
el.onclick = (ev) => ev.stopPropagation();
|
||||||
|
if (field?.attrs.editing) window.setTimeout(() => el.focus(), 0);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call a Python function with a text; its result may be an action list. */
|
||||||
|
private callWith(fn: any, text: string): any {
|
||||||
|
const call = callableOf(fn);
|
||||||
|
return call ? call(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
/** An invisible area that reports the pointer entering and leaving it, without taking clicks from what lies below. */
|
/** An invisible area that reports the pointer entering and leaving it, without taking clicks from what lies below. */
|
||||||
private mouseArea(node: any, ctx: Ctx): HTMLElement {
|
private mouseArea(node: any, ctx: Ctx): HTMLElement {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
@@ -1025,6 +1147,35 @@ export class ScreenManager {
|
|||||||
}
|
}
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
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]);
|
||||||
|
if (holds) return this.displayable(value.args[i + 1], depth + 1);
|
||||||
|
}
|
||||||
|
return document.createElement("div");
|
||||||
|
}
|
||||||
|
case "dynamicimage": { // DynamicImage("path/[variable].png"): the name is filled in each time it is drawn
|
||||||
|
const name = pyStr(a).replace(/\[([A-Za-z_][\w.]*)\]/g, (_m, source: string) => pyStr(this.host.py.eval(parseExpression(source))));
|
||||||
|
return this.displayable(name, depth + 1);
|
||||||
|
}
|
||||||
|
case "animation": { // pictures that take turns; the last one stays when it has no time
|
||||||
|
const box = document.createElement("div");
|
||||||
|
const pictures: HTMLElement[] = [];
|
||||||
|
const times: (number | null)[] = [];
|
||||||
|
for (let i = 0; i < value.args.length; i += 2) {
|
||||||
|
const picture = this.displayable(value.args[i], depth + 1);
|
||||||
|
if (picture) { pictures.push(picture); times.push(typeof value.args[i + 1] === "number" ? value.args[i + 1] : null); }
|
||||||
|
}
|
||||||
|
let at = 0;
|
||||||
|
const show = () => {
|
||||||
|
box.replaceChildren(pictures[at]);
|
||||||
|
const wait = times[at];
|
||||||
|
if (wait === null || !box.isConnected && at > 0) return;
|
||||||
|
window.setTimeout(() => { at = (at + 1) % pictures.length; show(); }, wait * 1000);
|
||||||
|
};
|
||||||
|
if (pictures.length) show();
|
||||||
|
return box;
|
||||||
|
}
|
||||||
case "trackcursor": return this.trackCursor(this.displayable(a, depth + 1), Number(b) || 1);
|
case "trackcursor": return this.trackCursor(this.displayable(a, depth + 1), Number(b) || 1);
|
||||||
case "composite": {
|
case "composite": {
|
||||||
const box = document.createElement("div");
|
const box = document.createElement("div");
|
||||||
@@ -1204,6 +1355,7 @@ export class ScreenManager {
|
|||||||
const tooltip = props.tooltip ? this.ev(props.tooltip) : undefined;
|
const tooltip = props.tooltip ? this.ev(props.tooltip) : undefined;
|
||||||
el.onmouseenter = () => {
|
el.onmouseenter = () => {
|
||||||
if (!sensitive) return;
|
if (!sensitive) return;
|
||||||
|
this.hovered = el;
|
||||||
hover = true;
|
hover = true;
|
||||||
paint();
|
paint();
|
||||||
if (typeof hoverSound === "string") this.host.play("sound", hoverSound, false);
|
if (typeof hoverSound === "string") this.host.play("sound", hoverSound, false);
|
||||||
@@ -1392,7 +1544,8 @@ export class ScreenManager {
|
|||||||
/** `at` transforms: static properties now, animated ones through the ATL runner. */
|
/** `at` transforms: static properties now, animated ones through the ATL runner. */
|
||||||
private transforms(el: HTMLElement, props: Record<string, Expr>, ctx: Ctx) {
|
private transforms(el: HTMLElement, props: Record<string, Expr>, ctx: Ctx) {
|
||||||
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 : "";
|
||||||
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.choose ? pick(truthy(this.ev(spec.choose.c)) ? spec.choose.then : spec.choose.else)
|
||||||
@@ -1468,6 +1621,7 @@ export class ScreenManager {
|
|||||||
case "ToggleSetMembership": return Array.isArray(a.args[0]) && a.args[0].some((x: any) => pyEq(x, a.args[1]));
|
case "ToggleSetMembership": return Array.isArray(a.args[0]) && a.args[0].some((x: any) => pyEq(x, a.args[1]));
|
||||||
case "ToggleFocus": return this.focus?.id === pyStr(a.args[0]);
|
case "ToggleFocus": return this.focus?.id === pyStr(a.args[0]);
|
||||||
case "SelectedIf": return truthy(a.args[0]);
|
case "SelectedIf": return truthy(a.args[0]);
|
||||||
|
case "InvertSelected": return !this.actionSelected(a.args[0]);
|
||||||
case "SetVariable": return this.valueOf(a.args[0], a.screen) === a.args[1];
|
case "SetVariable": return this.valueOf(a.args[0], a.screen) === a.args[1];
|
||||||
case "SetScreenVariable": return a.screen?.vars.get(a.args[0]) === a.args[1];
|
case "SetScreenVariable": return a.screen?.vars.get(a.args[0]) === a.args[1];
|
||||||
case "ToggleVariable": return truthy(this.valueOf(a.args[0], a.screen));
|
case "ToggleVariable": return truthy(this.valueOf(a.args[0], a.screen));
|
||||||
@@ -1580,6 +1734,21 @@ export class ScreenManager {
|
|||||||
}
|
}
|
||||||
case "FileDelete": this.host.deleteSlot(this.slotId(a, action.kw.page), truthy(action.kw.confirm ?? true)); return;
|
case "FileDelete": this.host.deleteSlot(this.slotId(a, action.kw.page), truthy(action.kw.confirm ?? true)); return;
|
||||||
case "FileTakeScreenshot": return;
|
case "FileTakeScreenshot": return;
|
||||||
|
case "InputEdit": {
|
||||||
|
const target = a as PyInstance;
|
||||||
|
target.attrs.editing = b === "toggle" ? !target.attrs.editing : b === "on";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case "CaptureFocus": this.focus = { id: pyStr(a ?? "default"), rect: this.screenRect(this.hovered ?? this.activated) }; return;
|
||||||
|
case "ClearFocus": if (!a || this.focus?.id === pyStr(a)) this.focus = null; return;
|
||||||
|
case "InvertSelected": this.perform(a, owner); return;
|
||||||
|
case "UploadSync":
|
||||||
|
case "DownloadSync": this.host.notify("Синхронизация недоступна в браузере"); return;
|
||||||
|
case "Confirm": {
|
||||||
|
const yes = b, no = c;
|
||||||
|
void this.host.ask(pyStr(a)).then((ok) => { this.run(ok ? yes : no, owner); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
case "FilePage": this.host.filePage = String(a); return;
|
case "FilePage": this.host.filePage = String(a); return;
|
||||||
case "FilePageNext":
|
case "FilePageNext":
|
||||||
case "FilePagePrevious": {
|
case "FilePagePrevious": {
|
||||||
@@ -1674,7 +1843,47 @@ export class ScreenManager {
|
|||||||
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
"Queue", "Stop", "Notify", "Quit", "MainMenu", "SelectedIf", "SensitiveIf", "ToggleScreen", "Language", "ShowMenu",
|
||||||
"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"]) out[kind] = make(kind);
|
"FilePage", "FilePageNext", "FilePagePrevious", "CaptureFocus", "ClearFocus", "InvertSelected", "UploadSync",
|
||||||
|
"DownloadSync", "Confirm"]) 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));
|
||||||
|
native(inputBase, "get_text", () => "");
|
||||||
|
native(inputBase, "set_text", () => null);
|
||||||
|
native(inputBase, "enter", (self) => (truthy(self.attrs.returnable) ? new ScreenAction("Return", [self.attrs.__text ?? this.host.py.attrOf(self, "get_text")()], {}, null) : null));
|
||||||
|
// Enable() and friends are called as `value.Enable()`: they must return the action itself
|
||||||
|
for (const [method, mode] of [["Enable", "on"], ["Disable", "off"], ["Toggle", "toggle"]] as const)
|
||||||
|
native(inputBase, method, (self) => new ScreenAction("InputEdit", [self, mode], {}, null));
|
||||||
|
registerNativeClass(inputBase);
|
||||||
|
const kwOfCall = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {};
|
||||||
|
const inputClass = (name: string, init: (self: PyInstance, a: any[], kw: Record<string, any>) => void,
|
||||||
|
get: (self: PyInstance) => any, set: (self: PyInstance, text: string) => void) => {
|
||||||
|
const cls = new PyClass(name, [inputBase]);
|
||||||
|
native(cls, "__init__", (self, ...a: any[]) => { const kw = kwOfCall(a); init(self, a.filter((x) => !x?.__kw), kw); return null; });
|
||||||
|
native(cls, "get_text", (self) => pyStr(get(self) ?? ""));
|
||||||
|
native(cls, "set_text", (self, text: string) => { set(self, text); return null; });
|
||||||
|
return registerNativeClass(cls);
|
||||||
|
};
|
||||||
|
out.InputValue = inputBase;
|
||||||
|
out.VariableInputValue = inputClass("VariableInputValue",
|
||||||
|
(self, a, kw) => { self.attrs.variable = pyStr(a[0] ?? kw.variable); self.attrs.returnable = truthy(kw.returnable ?? a[2] ?? false); },
|
||||||
|
(self) => this.host.py.lookup(self.attrs.variable),
|
||||||
|
(self, text) => { this.assign(self.attrs.variable, text, null); this.refresh(); });
|
||||||
|
out.ScreenVariableInputValue = inputClass("ScreenVariableInputValue",
|
||||||
|
(self, a, kw) => { self.attrs.variable = pyStr(a[0] ?? kw.variable); self.attrs.returnable = truthy(kw.returnable ?? a[2] ?? false); self.attrs.owner = this.rendering; },
|
||||||
|
(self) => self.attrs.owner?.vars.get(self.attrs.variable),
|
||||||
|
(self, text) => { self.attrs.owner?.vars.set(self.attrs.variable, text); this.refresh(); });
|
||||||
|
out.FieldInputValue = inputClass("FieldInputValue",
|
||||||
|
(self, a, kw) => { self.attrs.object = a[0] ?? kw.object; self.attrs.field = pyStr(a[1] ?? kw.field); },
|
||||||
|
(self) => self.attrs.object?.[self.attrs.field],
|
||||||
|
(self, text) => { if (self.attrs.object) self.attrs.object[self.attrs.field] = text; });
|
||||||
|
out.DictInputValue = inputClass("DictInputValue",
|
||||||
|
(self, a, kw) => { self.attrs.dict = a[0] ?? kw.dict; self.attrs.key = a[1] ?? kw.key; },
|
||||||
|
(self) => (self.attrs.dict instanceof PyDict ? self.attrs.dict.get(self.attrs.key) : ""),
|
||||||
|
(self, text) => { if (self.attrs.dict instanceof PyDict) self.attrs.dict.set(self.attrs.key, text); });
|
||||||
|
out.FilePageNameInputValue = inputClass("FilePageNameInputValue", () => {},
|
||||||
|
() => this.host.filePageName, (_self, text) => { this.host.filePageName = text; });
|
||||||
|
out.CurrentScreenName = () => this.rendering?.name ?? "";
|
||||||
// values of the save/load screens; screenshots are not stored, so a slot has none
|
// values of the save/load screens; screenshots are not stored, so a slot has none
|
||||||
const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {};
|
const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {};
|
||||||
const slotOf = (a: any[]) => this.slotId(a[0], kwOf(a).page);
|
const slotOf = (a: any[]) => this.slotId(a[0], kwOf(a).page);
|
||||||
@@ -1750,6 +1959,12 @@ export class ScreenManager {
|
|||||||
return { __character: true, name: args.length && typeof args[0] === "string" ? args[0] : null, color: kw.color ?? null, kw };
|
return { __character: true, name: args.length && typeof args[0] === "string" ? args[0] : null, color: kw.color ?? null, kw };
|
||||||
};
|
};
|
||||||
out.Text = shape("text");
|
out.Text = shape("text");
|
||||||
|
out.RenpyText = shape("text");
|
||||||
|
out.Animation = shape("animation");
|
||||||
|
out.ConditionSwitch = shape("conditionswitch");
|
||||||
|
out.DynamicImage = shape("dynamicimage"); // `from renpy.text.text import Text as RenpyText`
|
||||||
|
out.MatrixColor = shape("matrixcolor");
|
||||||
|
out.GLMovie = shape("movie");
|
||||||
out.Image = shape("image");
|
out.Image = shape("image");
|
||||||
out.ImageReference = shape("image");
|
out.ImageReference = shape("image");
|
||||||
out.Gallery = galleryClass({
|
out.Gallery = galleryClass({
|
||||||
@@ -1777,7 +1992,9 @@ export class ScreenManager {
|
|||||||
Sepia: (child: any) => new Displayable("filter", [child], { how: "sepia" }),
|
Sepia: (child: any) => new Displayable("filter", [child], { how: "sepia" }),
|
||||||
Alpha: (child: any, amount: number) => new Displayable("filter", [child, amount], { how: "alpha" }),
|
Alpha: (child: any, amount: number) => new Displayable("filter", [child, amount], { how: "alpha" }),
|
||||||
Blur: (child: any, radius: number) => new Displayable("filter", [child, radius], { how: "blur" }),
|
Blur: (child: any, radius: number) => new Displayable("filter", [child, radius], { how: "blur" }),
|
||||||
matrix: legacyMatrices,
|
matrix: Object.assign(legacyMatrix, { __statics: legacyMatrices }),
|
||||||
|
Image: shape("image"),
|
||||||
|
AlphaMask: shape("alphamask"),
|
||||||
};
|
};
|
||||||
out.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no);
|
out.If = (condition: any, yes: any = null, no: any = null) => (truthy(condition) ? yes : no);
|
||||||
out.GetTooltip = () => this.tooltip;
|
out.GetTooltip = () => this.tooltip;
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ export interface ScriptModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GameData {
|
export interface GameData {
|
||||||
|
/** JSON files of the game (paths inside game/); their text is in `dataText` once loaded. */
|
||||||
|
dataFiles?: 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. */
|
||||||
assetScale?: Record<string, number>;
|
assetScale?: Record<string, number>;
|
||||||
|
|||||||
@@ -183,3 +183,13 @@ test("a game's renpy.Displayable subclass renders a tree of pieces through renpy
|
|||||||
assert.deepEqual([tree.pieces[2].x, tree.pieces[2].y], [3, 4]);
|
assert.deepEqual([tree.pieces[2].x, tree.pieces[2].y], [3, 4]);
|
||||||
} finally { engine.destroy(); }
|
} finally { engine.destroy(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("starred items spread into tuples and lists", () => {
|
||||||
|
const { engine } = makeEngine(runtime());
|
||||||
|
try {
|
||||||
|
engine.newStore();
|
||||||
|
engine.store.rest = [2, 3];
|
||||||
|
const spread = engine.py.eval({ k: "tuple", v: [{ k: "const", v: 1 }, { k: "star", v: { k: "name", id: "rest" } }, { k: "const", v: 4 }] });
|
||||||
|
assert.deepEqual(spread, [1, 2, 3, 4]);
|
||||||
|
} finally { engine.destroy(); }
|
||||||
|
});
|
||||||
+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":"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
+16
@@ -33,3 +33,19 @@ result_loop = [x * 2 for x in bag]
|
|||||||
result_item = bag[1]
|
result_item = bag[1]
|
||||||
result_in = 4 in bag
|
result_in = 4 in bag
|
||||||
result_out = 9 in bag
|
result_out = 9 in bag
|
||||||
|
|
||||||
|
|
||||||
|
def walk(values, kind):
|
||||||
|
seen = []
|
||||||
|
for value in values:
|
||||||
|
if isinstance(value, kind):
|
||||||
|
yield value
|
||||||
|
elif isinstance(value, list):
|
||||||
|
yield from walk(value, kind)
|
||||||
|
|
||||||
|
|
||||||
|
result_found = list(walk([1, "a", [2, "b", [3]], 4], int))
|
||||||
|
result_iter = iter([7, 8])
|
||||||
|
result_first = next(result_iter)
|
||||||
|
result_second = next(result_iter)
|
||||||
|
result_default = next(result_iter, "done")
|
||||||
@@ -121,3 +121,12 @@ test("__hash__, __iter__, __getitem__ and __contains__ work on user classes", ()
|
|||||||
assert.equal(store.result_in, true);
|
assert.equal(store.result_in, true);
|
||||||
assert.equal(store.result_out, false);
|
assert.equal(store.result_out, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("generator functions hand out what they yield; iter and next walk a sequence", () => {
|
||||||
|
const { py, store } = runtime();
|
||||||
|
py.exec(ir("dunders"));
|
||||||
|
assert.deepEqual(store.result_found, [1, 2, 3, 4]);
|
||||||
|
assert.equal(store.result_first, 7);
|
||||||
|
assert.equal(store.result_second, 8);
|
||||||
|
assert.equal(store.result_default, "done");
|
||||||
|
});
|
||||||
Reference in new issue
Block a user