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:
1 parent
ea03cebf6a
commit
3923ee72fb
11 files changed
+698
-27
No files matched your search
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user