feat: generator interpreter for Python that waits for the player (Ren'Py)

Python containing renpy.input, narrator("..."), character calls, renpy.say,
renpy.pause, renpy.display_menu, renpy.with_statement or renpy.call_screen now
runs through loops, try, functions, methods and expressions.

Player
- python.ts: execG/evalG/callG run only the code that may wait (mayBlock);
  everything else stays on the synchronous fast path. A waiting call returns a
  BlockingCall; outside the generator interpreter it is an explicit error. The
  interpreter scope is set aside while the player decides
- engine: drives the generator and answers requests (dialogue, text input with
  length/allow/exclude, pause, choice menu, transition, call screen)
- renpy.show/hide/scene, transitions and transforms as Python values,
  Character(...) objects, Borders, set_tag_attributes

Converter
- blocking_names() lists game functions that may wait, transitively, and ships
  them as game.json "blocking"; character calls are allowed
- hide screen of an unknown screen is harmless; images defined by a parenthesised
  expression are supported

Hooked now passes the strict compatibility check (with Tomie and Twisted-Thoughts).
Tests: waiting.py fixture run through the generator interpreter; blocking-name analysis.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 14:22:38 +03:00
1 parent ea03cebf6a
commit 3923ee72fb
11 files changed
+698 -27

No files matched your search

+178 -7
View File
@@ -1,7 +1,7 @@
// PGVN interpreter + in-game UI.
import { AudioSystem } from "./audio";
import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, 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";
@@ -107,7 +107,11 @@ export class Engine {
};
this.py = new Python(scope);
this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {});
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins() };
this.py.blockingNames = new Set([
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
]);
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() };
const [w, h] = game.resolution;
this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306);
@@ -302,11 +306,30 @@ export class Engine {
call: (label: string) => {
throw new JumpSignal("call", label);
},
input: (...a: any[]) => {
const [prompt, def] = kwless(a);
// calls that wait for the player return a request; the generator interpreter answers it
input: (...a: any[]) => new BlockingCall("input", [kwless(a)[0] ?? "", kwless(a)[1] ?? kwOf(a).default ?? ""], kwOf(a)),
say: (who: any, what: any, ...a: any[]) => new BlockingCall("say", [who, what], kwOf(a)),
pause: (...a: any[]) => new BlockingCall("pause", [kwless(a)[0] ?? kwOf(a).delay ?? null], kwOf(a)),
display_menu: (items: any, ...a: any[]) => new BlockingCall("menu", [items], kwOf(a)),
with_statement: (transition: any) => new BlockingCall("with", [transition]),
call_screen: (name: any, ...a: any[]) => new BlockingCall("screen", [name, ...kwless(a)], kwOf(a)),
show: (name: any, ...a: any[]) => {
const kw = kwOf(a);
const v = window.prompt(pyStr(prompt ?? ""), pyStr(def ?? kw.default ?? ""));
return (v ?? pyStr(def ?? kw.default ?? "")).slice(0, kw.length ?? 1000);
const parts = String(name).split(" ");
const picture = this.resolveImage(parts);
if (!picture) return null;
const at = ((kw.at_list ?? []) as any[]).filter((t) => t && t.__transform).map((t) => ({ name: t.__transform }));
this.renderer.show(picture, kw.tag ?? parts[0], at, { zorder: kw.zorder ?? undefined, behind: kw.behind ?? undefined });
return null;
},
hide: (name: any) => {
this.renderer.hide(String(name).split(" ")[0]);
return null;
},
scene: () => {
this.renderer.scene();
this.clearNvl();
return null;
},
has_label: (l: string) => this.labels.has(l),
seen_label: (l: string) => truthy(this.persistent._seen?.includes?.(l)),
@@ -332,6 +355,7 @@ export class Engine {
register_shader: () => null,
register_statement: () => null,
redraw: () => null,
set_tag_attributes: () => null,
stop_predict: () => null,
map_event: () => null,
count_displayables_in_layer: () => 0,
@@ -367,6 +391,7 @@ export class Engine {
// a read-only view: `"name" in globals()` works, writes do not reach the game
globals: () => new PyDict(Object.entries(this.store)),
print: () => null,
Borders: (...a: number[]) => a,
...pythonModules(),
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
_window_show: () => { this.windowShown = true; return null; },
@@ -427,6 +452,147 @@ export class Engine {
}
}
/** Python names that make the player wait: the game's characters and narrator, Character(...) objects. */
private waitingBuiltins(): Record<string, any> {
const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {};
const posOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw ? a.slice(0, -1) : a);
const speak = (who: any) => (...a: any[]) => new BlockingCall("say", [who, posOf(a)[0]], kwOf(a));
const out: Record<string, any> = { narrator: speak(null) };
for (const id of Object.keys(this.game.characters)) out[id] = speak(id);
out.Character = (...a: any[]) => {
const kw = kwOf(a);
const speaker: any = (...b: any[]) => new BlockingCall("say", [speaker, posOf(b)[0]], kwOf(b));
speaker.__character = true;
speaker.name = typeof posOf(a)[0] === "string" ? posOf(a)[0] : null;
speaker.color = kw.color ?? null;
return speaker;
};
// transitions are values in Python (`renpy.with_statement(dissolve)`), transforms are named by at_list
const transitions: Record<string, any> = {
dissolve: { t: "dissolve", d: 0.5 }, fade: { t: "fade", out: 0.5, hold: 0, in: 0.5, color: "#000000" },
move: { t: "move", d: 0.5 }, ease: { t: "move", d: 0.5 }, vpunch: { t: "punch", dir: "v", d: 0.3 },
hpunch: { t: "punch", dir: "h", d: 0.3 }, ...this.game.transitions,
};
Object.assign(out, transitions);
out.Dissolve = (d = 0.5) => ({ t: "dissolve", d });
out.Fade = (outTime = 0.5, hold = 0, inTime = 0.5, ...a: any[]) =>
({ t: "fade", out: outTime, hold, in: inTime, color: kwOf(a).color ?? "#000000" });
for (const name of [...Object.keys(this.game.transforms), ...Object.keys(this.game.atl ?? {}),
"left", "right", "center", "truecenter", "topleft", "topright", "top", "default", "reset", "offscreenleft",
"offscreenright"]) {
if (!(name in out)) out[name] = { __transform: name };
}
return out;
}
/** Run a generator of the Python interpreter: each waiting call is answered here. */
private async drive<T>(generator: Generator<BlockingCall, T, any>): Promise<T> {
let step = generator.next();
while (!step.done) {
let answer: any;
try {
answer = await this.answerBlocking(step.value);
} catch (err) {
if (err instanceof Restart || err instanceof JumpSignal) {
generator.return(undefined as T);
throw err;
}
step = generator.throw(err); // Python code may catch it
continue;
}
step = generator.next(answer);
}
return step.value;
}
private async answerBlocking(request: BlockingCall): Promise<any> {
const [first, second] = request.args;
switch (request.kind) {
case "say": {
const node: Node = { op: "say", what: "", whatExpr: { k: "const", v: pyStr(second) },
interact: request.kw.interact !== false };
if (typeof first === "string") node.who = first;
else if (first && first.__character) node.whoObj = first;
await this.say(node);
return null;
}
case "pause":
await this.pause(first === null || first === undefined ? null : Number(first));
return null;
case "input":
return this.askText(pyStr(first), pyStr(second), request.kw);
case "menu":
return this.chooseItem(first);
case "with":
await this.renderer.transition(first && typeof first === "object" ? first : null);
return null;
case "screen": {
const name = String(first);
const value = await this.wait<any>((done) => this.screens.show(name, request.args.slice(1), request.kw, { call: true, onReturn: done }));
this.screens.hideCall(name);
return value ?? null;
}
}
throw new Error(`Неизвестный запрос ${request.kind}`);
}
/** renpy.input(): a text field over the scene. */
private askText(prompt: string, initial: string, kw: Record<string, any>): Promise<string> {
this.interactionStart();
this.setSkip(false);
return this.wait<string>((done) => {
this.menuEl.textContent = "";
const box = el("div", "pg-input");
if (prompt) {
const label = el("div", "pg-input-prompt");
renderText(label, parseText(this.interp(prompt)), Infinity, this.baseSize);
box.append(label);
}
const input = document.createElement("input");
input.type = "text";
input.value = initial;
if (typeof kw.length === "number") input.maxLength = kw.length;
const ok = el("button", "pg-choice", "OK");
const submit = () => {
let value = input.value;
if (typeof kw.exclude === "string") value = [...value].filter((c) => !kw.exclude.includes(c)).join("");
if (typeof kw.allow === "string") value = [...value].filter((c) => kw.allow.includes(c)).join("");
done(value);
};
ok.onclick = (ev) => { ev.stopPropagation(); submit(); };
input.onkeydown = (ev) => {
ev.stopPropagation();
if (ev.key === "Enter") { ev.preventDefault(); submit(); }
};
input.onclick = (ev) => ev.stopPropagation();
box.append(input, ok);
this.menuEl.append(box);
this.menuEl.classList.remove("pg-hidden");
input.focus();
}).finally(() => this.menuEl.classList.add("pg-hidden"));
}
/** renpy.display_menu(): a list of (caption, value) pairs; a None value is a caption line. */
private chooseItem(items: any): Promise<any> {
this.interactionStart();
this.setSkip(false);
return this.wait<any>((done) => {
this.menuEl.textContent = "";
for (const item of (Array.isArray(items) ? items : []) as any[]) {
const [caption, value] = Array.isArray(item) ? item : [item, item];
if (value === null || value === undefined) {
this.menuEl.append(el("div", "pg-caption", pyStr(caption)));
continue;
}
const button = el("button", "pg-choice");
renderText(button, parseText(this.interp(pyStr(caption))), Infinity, this.baseSize);
button.onclick = (ev) => { ev.stopPropagation(); done(value); };
this.menuEl.append(button);
}
this.menuEl.classList.remove("pg-hidden");
}).finally(() => this.menuEl.classList.add("pg-hidden"));
}
private screenHost(): ScreenHost {
const engine = this;
return {
@@ -834,7 +1000,8 @@ export class Engine {
return;
case "exec":
try {
this.py.exec(node.code);
if (this.py.mayBlock(node.code)) await this.drive(this.py.execG(node.code));
else this.py.exec(node.code);
} catch (err) {
if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
@@ -899,6 +1066,10 @@ export class Engine {
}
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
if (node.whoObj) {
const speaker = node.whoObj;
return { char: { color: speaker.color ?? undefined }, name: this.interp(this.tr.str(speaker.name ?? "")), mode: "adv" };
}
let who: string | undefined = node.who;
if (node.whoExpr) {
const value = this.safeEval(node.whoExpr);