feat: improve RenPy conversion and support MIST runtime
CI / frontend (push) Failing after 15s
CI / backend (push) Failing after 37s

This commit is contained in:
mrleo1nid committed 2026-10-25 23:16:33 +03:00
1 parent f7f7f5b1b3
commit f9eb5c1212
44 files changed
+2822 -174

No files matched your search

+187 -13
View File
@@ -1,14 +1,16 @@
// PGVN interpreter + in-game UI.
import { AudioSystem } from "./audio";
import { cutscene } from "./cutscene";
import { VolumeStore } from "./volume-store";
import { LiveImages } from "../imager/live";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, 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";
import { naniText } from "./naninovel";
import { ScreenManager, type ScreenHost } from "./screens";
import { Displayable, ScreenManager, type ScreenHost } from "./screens";
import { musicRoomClass } from "./musicroom";
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";
@@ -30,6 +32,8 @@ export class Engine {
private labels = new Map<string, LabelRef>();
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
private originalImages: Record<string, ImageDef>;
private registeredImages: Record<string, ImageDef> = {};
private store: Record<string, any> = {};
private builtins: Record<string, any> = {};
private persistent: Record<string, any>;
@@ -89,6 +93,7 @@ export class Engine {
audioKey: string | null = null,
) {
this.volumes = new VolumeStore(audioKey);
this.originalImages = { ...game.images };
for (const mod of modules) {
let global = "";
mod.labels.forEach((label, index) => {
@@ -130,6 +135,7 @@ export class Engine {
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 ?? []),
"ui.interact", "renpy.movie_cutscene",
]);
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() };
@@ -156,6 +162,7 @@ export class Engine {
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
});
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
this.builtins.MusicRoom = musicRoomClass(this.audio);
this.buildUI();
if (manifest.versionId !== undefined) this.liveImages = new LiveImages(manifest.versionId, (message) => this.notify(`Улучшение графики: ${message}`));
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
@@ -223,6 +230,45 @@ export class Engine {
return { def: this.game.images[best.key], name: best.key.split(" ") };
}
private indexImage(name: string) {
const [tag, ...attrs] = name.split(" ");
const entries = this.imagesByTag.get(tag) ?? [];
if (!entries.some((entry) => entry.key === name)) entries.push({ attrs, key: name });
this.imagesByTag.set(tag, entries);
}
private registerImage(name: string, definition: ImageDef) {
Object.defineProperty(this.game.images, name, { value: definition, enumerable: true, configurable: true, writable: true });
Object.defineProperty(this.registeredImages, name, { value: definition, enumerable: true, configurable: true, writable: true });
this.indexImage(name);
}
/** Runtime renpy.image declarations become the same serializable image IR as static ones. */
private imageDefinition(value: any, depth = 0): ImageDef {
if (depth > 8) throw new Error("Слишком глубокое определение изображения");
if (typeof value === "string") return /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value }
: Object.hasOwn(this.game.images, value) ? { ref: value } : { file: value };
if (!(value instanceof Displayable)) throw new Error("Неподдерживаемое значение renpy.image");
const [a, b, c] = value.args;
switch (value.kind) {
case "image": return this.imageDefinition(a, depth + 1);
case "scale": {
const child = this.imageDefinition(a, depth + 1);
return { ...child, props: { ...child.props, size: [b, c] } };
}
case "transform": {
const child = this.imageDefinition(a, depth + 1);
return { ...child, props: { ...child.props, ...value.kw } };
}
case "solid": return { color: pyStr(a) };
case "movie": return { movie: pyStr(value.kw.play ?? a), movieMask: value.kw.mask ? pyStr(value.kw.mask) : undefined,
movieLoop: value.kw.loop !== false };
case "text": return { text: pyStr(a) };
case "null": return { composite: [] };
}
throw new Error(`renpy.image: не поддерживается ${value.kind}`);
}
/** Attributes of a layeredimage: new ones replace current ones of the same group. */
private resolveLayered(tag: string, def: ImageDef, attrs: string[]): ResolvedImage {
const layers = def.layered!;
@@ -318,7 +364,13 @@ export class Engine {
},
stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0),
set_volume: () => {},
set_pause: () => {},
set_pause: (...a: any[]) => {
this.audio.setPaused(kwOf(a).channel ?? ch, truthy(kwless(a)[0] ?? true));
return null;
},
get_pause: (...a: any[]) => this.audio.paused(kwOf(a).channel ?? kwless(a)[0] ?? ch),
get_pos: (...a: any[]) => this.audio.position(kwOf(a).channel ?? kwless(a)[0] ?? ch),
get_duration: (...a: any[]) => this.audio.duration(kwOf(a).channel ?? kwless(a)[0] ?? ch),
set_pan: () => {},
get_playing: () => this.audio.current(ch)?.file ?? null,
is_playing: () => this.audio.current(ch) !== null,
@@ -342,6 +394,9 @@ export class Engine {
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)),
movie_cutscene: (...a: any[]) => new BlockingCall("movie", kwless(a), kwOf(a)),
free_memory: () => null, // browser owns the decoded-media cache
image: (name: any, value: any) => { this.registerImage(Array.isArray(name) ? name.join(" ") : String(name), this.imageDefinition(value)); return null; },
show: (name: any, ...a: any[]) => {
const kw = kwOf(a);
const parts = String(name).split(" ");
@@ -410,6 +465,34 @@ export class Engine {
change_language: (code: any) => void this.setLanguage(code === null || code === undefined ? this.game.sourceLang ?? "und" : String(code)),
seen_image: (name: any) => this.seenImages.has(String(name)),
block_rollback: () => null,
choice_for_skipping: () => null,
// text effects drawn by GPU shaders: the words stay, only the glow is missing
register_textshader: () => null,
save_persistent: () => null, // persistent data is written on every change
mark_label_seen: (label: any) => {
const seen: string[] = this.persistent._seen ?? [];
if (!seen.includes(String(label))) this.persistent._seen = [...seen, String(label)];
return null;
},
get_game_runtime: () => (performance.now() - this.startedAt) / 1000,
translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null),
substitute: (text: any) => this.interp(pyStr(text)),
return_statement: () => {
throw new JumpSignal("return", "");
},
checkpoint: () => null,
start_predict: () => null,
start_predict_screen: () => null,
stop_predict_screen: () => null,
// named slots; the name is limited to what the server accepts
save: (name: any) => {
void this.save(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32), true);
return null;
},
load: (name: any) => {
void this.load(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32));
return null;
},
transition: () => null,
};
const renpy = new Proxy(renpyImpl, {
@@ -423,7 +506,7 @@ export class Engine {
return {
renpy,
persistent: this.persistent,
config: {},
config: { gestures: {}, character_id_prefixes: [], screen_width: this.game.resolution[0], screen_height: this.game.resolution[1] },
// games branch on the Ren'Py language name ("russian"); None means the source language
preferences: this.languagePrefs(),
_preferences: this.languagePrefs(),
@@ -456,6 +539,16 @@ export class Engine {
},
Borders: (...a: number[]) => a,
...pythonModules(),
...nativeClasses(),
build: { ...this.game.build },
ui: { adjustment: () => ({ value: 0, range: 1, page: 0 }),
interact: () => new BlockingCall("interact") },
divmod: (a: number, b: number) => [Math.floor(a / b), ((a % b) + b) % b],
pow: (a: number, b: number, m?: number) => (m ? Number((BigInt(a) ** BigInt(b)) % BigInt(m)) : a ** b),
_p: (text: any) => this.tr.str(pyStr(text)).replace(/[ \t]*\n[ \t]*/g, " ").trim(),
achievement: this.achievementApi(),
// objects the game can reach, as the garbage collector would list them
gc: { get_objects: () => liveObjects(this.store), collect: () => 0 },
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
_window_show: () => { this.windowShown = true; return null; },
...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
@@ -597,10 +690,45 @@ export class Engine {
this.screens.hideCall(name);
return value ?? null;
}
case "interact": {
this.interactionStart();
this.screens.refresh();
let cancel = () => {};
try { return await this.wait<any>((done) => { cancel = this.screens.interact(done); }); }
finally { cancel(); }
}
case "movie":
return this.movieCutscene(request.args, request.kw);
}
throw new Error(`Неизвестный запрос ${request.kind}`);
}
private async movieCutscene(args: any[], kw: Record<string, any>): Promise<boolean> {
const filename = this.interp(pyStr(args[0] ?? kw.filename));
const src = this.fileUrl(filename);
if (!src) throw new Error(`Не найден ролик: ${filename}`);
const delay = args[1] ?? kw.delay ?? null;
const loops = Number(args[2] ?? kw.loops ?? 0);
if ((delay !== null && (!Number.isFinite(delay) || delay < -1)) || !Number.isInteger(loops) || loops < -1)
throw new Error("Некорректная длительность или число повторений ролика");
const stopMusic = args[3] ?? kw.stop_music ?? true;
const paused = this.audio.paused("music");
if (stopMusic) this.audio.setPaused("music", true);
const restoreScreens = this.screens.suspend();
const movie = cutscene(this.container, src, { delay, loops, volume: this.audio.mixerVolume("sfx") });
try {
const result = await this.wait<{ value?: boolean; error?: any }>((done) => {
movie.finished.then((value) => done({ value }), (error) => done({ error }));
});
if (result.error) throw result.error;
return result.value!;
} finally {
movie.destroy();
restoreScreens();
if (stopMusic) this.audio.setPaused("music", paused);
}
}
/** renpy.input(): a text field over the scene. */
private askText(prompt: string, initial: string, kw: Record<string, any>): Promise<string> {
this.interactionStart();
@@ -658,9 +786,46 @@ export class Engine {
}).finally(() => this.menuEl.classList.add("pg-hidden"));
}
private startedAt = performance.now();
/** Ren'Py's achievement API on top of persistent data (there is no Steam in the browser). */
private achievementApi() {
const got = (): string[] => this.persistent._achievements ?? [];
return {
steam: null,
register: () => null,
sync: () => null,
has: (name: any) => got().includes(String(name)),
grant: (name: any) => {
if (!got().includes(String(name))) {
this.persistent._achievements = [...got(), String(name)];
this.notify(`Достижение: ${pyStr(name)}`);
}
return null;
},
progress: () => null,
clear: (name: any) => {
this.persistent._achievements = got().filter((n) => n !== String(name));
return null;
},
clear_all: () => { this.persistent._achievements = []; return null; },
};
}
private screenHost(): ScreenHost {
const engine = this;
return {
error: (message) => this.fail(new Error(message)),
get audio() { return engine.audio; },
setMixer: (name, value) => {
this.audio.setMixer(name, value);
const key = name === "sfx" ? "sound" : name;
if (key in this.settings) {
(this.settings as any)[key] = value;
this.audio.setVolumes(this.settings);
this.persistSettings("audio");
}
},
py: this.py,
fileUrl: (path) => this.fileUrl(path),
image: (value) => this.screenImage(value),
@@ -725,6 +890,10 @@ export class Engine {
private resetStore() {
this.screens.clear();
this.game.images = { ...this.originalImages };
this.registeredImages = {};
this.imagesByTag.clear();
for (const name of Object.keys(this.game.images)) this.indexImage(name);
this.store = {};
try {
this.py.exec(this.game.init);
@@ -798,11 +967,7 @@ export class Engine {
if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name;
const anchor = this.labels.has(name) ? null : this.anchors.get(name);
if (!this.labels.has(name) && !anchor) {
this.notify(`Метка «${target}» не найдена`);
console.warn("[pgvn] нет метки", target);
if (frame) this.stack.pop();
this.advance();
return;
throw new Error(`Метка «${target}» не найдена`);
}
const params = anchor ? anchor.params : this.labels.get(name)!.label.params;
if (params?.length) this.bindParams(params, args, frame);
@@ -905,6 +1070,7 @@ export class Engine {
} catch (err) {
if (err instanceof JumpSignal) {
if (err.kind === "jump") this.jump(err.target);
else if (err.kind === "return") this.doReturn();
else this.call(err.target);
} else throw err;
}
@@ -915,8 +1081,7 @@ export class Engine {
if (err.message === "__quit__") this.onExit();
return;
}
console.error(err);
this.notify(`Ошибка плеера: ${(err as Error).message}`);
this.fail(err);
} finally {
if (gen === this.gen) this.running = false;
}
@@ -1073,8 +1238,7 @@ export class Engine {
else this.py.exec(node.code);
} catch (err) {
if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
console.warn("[pgvn] ошибка python", err, node.code);
throw err;
}
this.advance();
return;
@@ -1383,6 +1547,14 @@ export class Engine {
window.setTimeout(() => this.notifyEl.classList.add("pg-hidden"), 3000);
}
private fail(error: any) {
this.gen++;
this.wakeAll();
this.running = false;
this.screens.suspend();
this.notify(`Ошибка плеера: ${error?.message ?? String(error)}`);
}
// --- session control --------------------------------------------------------------
private restart(setup: () => void) {
@@ -1487,6 +1659,7 @@ export class Engine {
store: encodeValue(this.store),
screens: encodeValue(this.screens.snapshot()),
scene: this.renderer.snapshot(),
registeredImages: { ...this.registeredImages },
music: this.audio.current("music"),
window: this.windowShown,
camera: this.renderer.cameraAt,
@@ -1535,6 +1708,7 @@ export class Engine {
/** Put the game into the state described by a save (also used to come back from a replay). */
private applySave(data: SaveData) {
this.resetStore();
for (const [name, definition] of Object.entries(data.registeredImages ?? {})) this.registerImage(name, definition);
Object.assign(this.store, decodeValue(data.store));
this.screens.restore(decodeValue(data.screens ?? []));
this.renderer.scene();