feat: eval/exec of strings, gallery Replay, callable actions; iNSight and SomethingBetter now pass
- pyparse.ts: parser for expressions and simple statements built at run time, producing
the sandbox's own IR; backs eval() and exec() (e.g. exec("persistent." + var + " = 0"))
- Replay/EndReplay and renpy.end_replay: the engine stashes the full game state, runs the
label with the given scope and restores everything when it ends; locked replays are
insensitive. Save loading shared as applySave()
- Screen actions are callable (MainMenu(confirm=False)())
- jump main_menu_screen is Ren'Py's own label: it opens the player's main menu
- Screens: a closing quote ends the word before a letter ("text"size 15) but not before
an operator ("a"+b); Text(...) pictures in image blocks
- README: current capabilities and limits of the Ren'Py support
Strict compatibility check on the local collection: Tomie, Twisted-Thoughts, Hooked,
iNSight-of-you and SomethingBetter pass; TheFixer (custom AlphaMask wardrobe, missing
labels) and That_New_Teacher (custom statements, Render API) do not.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3923ee72fb
commit
3ac4f0371f
11 files changed
+448
-36
No files matched your search
@@ -8,6 +8,7 @@ import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||||
import { naniText } from "./naninovel";
|
||||
import { ScreenManager, type ScreenHost } from "./screens";
|
||||
import { pythonModules } from "./pymodules";
|
||||
import { parseExpression, parseStatements } from "./pyparse";
|
||||
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
|
||||
import type {
|
||||
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
|
||||
@@ -39,6 +40,7 @@ export class Engine {
|
||||
private waiters: (() => void)[] = [];
|
||||
private screens: ScreenManager;
|
||||
private screenFonts = new Map<string, string>();
|
||||
private replayStash: SaveData | null = null;
|
||||
private seenImages = new Set<string>();
|
||||
private running = false;
|
||||
|
||||
@@ -344,7 +346,10 @@ export class Engine {
|
||||
version: () => "pgvn",
|
||||
...this.screens.renpyApi(),
|
||||
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
|
||||
end_replay: () => null,
|
||||
end_replay: () => {
|
||||
this.endReplay();
|
||||
return null;
|
||||
},
|
||||
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
|
||||
loadable: (path: any) => this.fileUrl(String(path)) !== null,
|
||||
is_init_phase: () => false,
|
||||
@@ -391,6 +396,13 @@ 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,
|
||||
// code built at run time ("persistent." + name + " = 0") goes through a parser producing the same
|
||||
// sandboxed IR; there is no other way to run source text
|
||||
eval: (source: any) => this.py.eval(parseExpression(pyStr(source))),
|
||||
exec: (source: any) => {
|
||||
this.py.exec(parseStatements(pyStr(source)));
|
||||
return null;
|
||||
},
|
||||
Borders: (...a: number[]) => a,
|
||||
...pythonModules(),
|
||||
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
|
||||
@@ -614,6 +626,8 @@ export class Engine {
|
||||
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
|
||||
},
|
||||
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"),
|
||||
startReplay: (label, scope) => this.startReplay(label, scope),
|
||||
endReplay: () => this.endReplay(),
|
||||
quickSave: () => void this.save("quick"),
|
||||
quickLoad: () => void this.load("quick"),
|
||||
transform: (name) => this.game.transforms[name] ?? null,
|
||||
@@ -726,6 +740,7 @@ export class Engine {
|
||||
}
|
||||
|
||||
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
|
||||
if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__";
|
||||
if (target === "__main_menu__" || target === "__quit__") {
|
||||
throw new Restart(target);
|
||||
}
|
||||
@@ -776,6 +791,10 @@ export class Engine {
|
||||
private doReturn() {
|
||||
const frame = this.stack.pop();
|
||||
if (!frame) {
|
||||
if (this.replayStash) {
|
||||
this.endReplay(); // the replayed label ran out: back to the game
|
||||
return;
|
||||
}
|
||||
this.cur = { label: "", path: [0] };
|
||||
return;
|
||||
}
|
||||
@@ -1459,27 +1478,54 @@ export class Engine {
|
||||
frames.forEach((f, i) => Object.assign(f, mapped[i]));
|
||||
}
|
||||
if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return;
|
||||
this.replayStash = null;
|
||||
this.restart(() => this.applySave(data));
|
||||
}
|
||||
|
||||
/** Put the game into the state described by a save (also used to come back from a replay). */
|
||||
private applySave(data: SaveData) {
|
||||
this.resetStore();
|
||||
Object.assign(this.store, decodeValue(data.store));
|
||||
this.screens.restore(decodeValue(data.screens ?? []));
|
||||
this.renderer.scene();
|
||||
for (const s of data.scene) {
|
||||
const img = this.resolveImage(s.name);
|
||||
if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder });
|
||||
}
|
||||
this.renderer.commit();
|
||||
this.audio.stopAll();
|
||||
if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 });
|
||||
this.windowShown = data.window;
|
||||
this.renderer.camera(data.camera ?? []);
|
||||
this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) }));
|
||||
this.cur = data.cur;
|
||||
this.lastSay = null;
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/** Gallery replay: run a label with the given variables, then return to exactly where the game was. */
|
||||
private startReplay(label: string, scope: Record<string, any> | null) {
|
||||
if (!this.labels.has(label)) {
|
||||
this.notify(`Метка «${label}» не найдена`);
|
||||
return;
|
||||
}
|
||||
if (!this.replayStash) this.replayStash = this.snapshotState();
|
||||
this.restart(() => {
|
||||
this.resetStore();
|
||||
Object.assign(this.store, decodeValue(data.store));
|
||||
this.screens.restore(decodeValue(data.screens ?? []));
|
||||
this.renderer.scene();
|
||||
for (const s of data.scene) {
|
||||
const img = this.resolveImage(s.name);
|
||||
if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder });
|
||||
}
|
||||
this.renderer.commit();
|
||||
this.audio.stopAll();
|
||||
if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 });
|
||||
this.windowShown = data.window;
|
||||
this.renderer.camera(data.camera ?? []);
|
||||
this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) }));
|
||||
this.cur = data.cur;
|
||||
this.lastSay = null;
|
||||
this.history = [];
|
||||
for (const [key, value] of Object.entries(scope ?? {})) this.store[key] = value;
|
||||
this.store._in_replay = label;
|
||||
this.stack = [];
|
||||
this.cur = { label: "", path: [0] };
|
||||
this.guardedJump(label);
|
||||
});
|
||||
}
|
||||
|
||||
private endReplay() {
|
||||
const stash = this.replayStash;
|
||||
if (!stash) return;
|
||||
this.replayStash = null;
|
||||
this.restart(() => this.applySave(stash));
|
||||
}
|
||||
|
||||
private setSkip(on: boolean) {
|
||||
this.skip = on;
|
||||
this.renderer.fast = on;
|
||||
|
||||
Reference in new issue
Block a user