feat: improve RenPy conversion and support MIST runtime
This commit is contained in:
1 parent
f7f7f5b1b3
commit
f9eb5c1212
44 files changed
+2822
-174
No files matched your search
+187
-13
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user