Files
pgames-manager/frontend/src/player/engine.ts
T
mrleo1nidandClaude Sonnet 5.5 16ba20816c
CI / frontend (push) Failing after 21s
CI / backend (push) Failing after 36s
feat: File* save/load actions, hashlib/uuid, dict spread and small renpy helpers in the player
Games with their own save screens work: FileAction/FileSave/FileLoad/FileDelete/FilePage*, FileLoadable,
FileTime, FileSaveName, FileJson and friends use browser slots (a slot has no screenshot). Added
renpy.has_image/has_screen/list_files/get_mouse_pos/log/jump_out_of_context/random.seed,
renpy.translation.translate_string, type/id/vars/Flatten/LiveComposite/Composite, bare defaultdict/
deepcopy, md5/sha256 (checked against test vectors), uuid4, time.monotonic, re.compile().fullmatch,
math.isfinite and `{**a, "k": v}`. The converter whitelists them and accepts the hashlib/uuid imports.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 04:02:22 +03:00

2361 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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, 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 { 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";
import type {
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
} from "./types";
export type { SaveApi, SaveMeta } from "./engine-util";
interface LabelRef {
label: Label;
module: ScriptModule;
index: number;
}
export class Engine {
private liveImages?: LiveImages;
private imageScales = new Map<string, number>();
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>;
private py: Python;
private renderer: Renderer;
private audio: AudioSystem;
private settings: Settings;
private cur: Frame = { label: "", path: [0] };
private stack: Frame[] = [];
private gen = 0;
private waiters: (() => void)[] = [];
private screens: ScreenManager;
private screenFonts = new Map<string, string>();
/** Settings shared by all games; `settings` is what this game actually uses. */
private sharedSettings: Settings;
private ownVolumes = false;
private volumes: VolumeStore;
private replayStash: SaveData | null = null;
private seenImages = new Set<string>();
private running = false;
private skip = false;
private auto = false;
private windowShown = true;
private lastSay: { name: string; text: string; color?: string; char?: CharacterDef } | null = null;
private history: { name: string; text: string }[] = [];
private nvlLines: HTMLElement[] = [];
private interactions = 0;
// UI
private container: HTMLDivElement;
private textbox!: HTMLDivElement;
private namebox!: HTMLDivElement;
private textEl!: HTMLDivElement;
private ctc!: HTMLSpanElement;
private menuEl!: HTMLDivElement;
private nvlEl!: HTMLDivElement;
private centeredEl!: HTMLDivElement;
private quick!: HTMLDivElement;
private screen!: HTMLDivElement;
private notifyEl!: HTMLDivElement;
private baseSize: number;
private resizeObserver: ResizeObserver;
constructor(
private root: HTMLElement,
private game: GameData,
private manifest: Manifest,
modules: ScriptModule[],
private saves: SaveApi,
private persistentKey: string,
private onExit: () => void,
private tr: Translator,
private loadLocale: (lang: string) => Promise<Translator>,
/** Storage key of this game's own volumes; without it the game always uses the shared ones. */
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) => {
if (label.name.startsWith(".")) label.name = global + label.name;
else global = label.name.split(".")[0];
this.labels.set(label.name, { label, module: mod, index });
});
for (const [name, anchor] of Object.entries(mod.anchors ?? {})) this.anchors.set(name, anchor);
}
for (const key of Object.keys(game.images)) {
const [tag, ...attrs] = key.split(" ");
if (!this.imagesByTag.has(tag)) this.imagesByTag.set(tag, []);
this.imagesByTag.get(tag)!.push({ attrs, key });
}
this.sharedSettings = { ...loadJSON("pgvn:settings", DEFAULT_SETTINGS) };
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) {
const value = this.sharedSettings[key];
const [min, max] = key === "cps" ? [0, 150] : key === "autoDelay" ? [0.3, 4] : [0, 1];
this.sharedSettings[key] = typeof value === "number" && Number.isFinite(value)
? Math.max(min, Math.min(max, value)) : DEFAULT_SETTINGS[key];
}
// a game that has no volumes of its own (a first launch) plays with the shared ones
this.settings = { ...this.sharedSettings };
const own = this.volumes.read();
if (own) {
Object.assign(this.settings, own);
this.ownVolumes = true;
}
this.persistent = this.makePersistent();
const scope: Scope = {
get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]),
has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n),
set: (n, v) => void (this.store[n] = v),
del: (n) => void delete this.store[n],
};
this.py = new Python(scope);
this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {});
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() };
const [w, h] = game.resolution;
this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306);
this.container = el("div", "pg-root");
root.append(this.container);
this.renderer = new Renderer(this.container, w, h, {
url: (f) => this.fileUrl(f),
assetScale: (url) => this.imageScales.get(url) ?? 1,
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
evalCond: (c) => truthy(this.safeEval(c)),
transform: (name) => game.transforms[name] ?? null,
atl: (name) => game.atl?.[name] ?? null,
evalExpr: (expr, frame) => {
const saved = this.py.locals;
this.py.locals = frame ? [...saved, frame] : saved;
try {
return this.safeEval(expr);
} finally {
this.py.locals = saved;
}
},
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));
this.resizeObserver.observe(this.container);
this.renderer.fit(this.container);
window.addEventListener("keydown", this.onKey);
window.addEventListener("keyup", this.onKeyUp);
window.addEventListener("pointermove", this.onPointer);
}
/** The pointer in the game's own coordinates (`renpy.get_mouse_pos`). */
private mousePos: [number, number] = [0, 0];
private onPointer = (event: PointerEvent) => {
const box = this.container.getBoundingClientRect();
const [w, h] = this.game.resolution ?? [1920, 1080];
if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)];
};
destroy() {
window.removeEventListener("pointermove", this.onPointer);
this.liveImages?.destroy();
this.screens.destroy();
this.gen++;
this.wakeAll();
this.audio.stopAll();
this.resizeObserver.disconnect();
window.removeEventListener("keydown", this.onKey);
window.removeEventListener("keyup", this.onKeyUp);
this.container.remove();
}
// --- files & images ----------------------------------------------------------
fileUrl(path: string): string | null {
const clean = path.replace(/^<[^>]*>/, "").replace(/\\/g, "/").replace(/^\/+/, "");
let logical: string | undefined = this.manifest.files[clean] ? clean : undefined;
if (!logical) {
const low = clean.toLowerCase();
for (const prefix of ["", "images/", "audio/", "music/", "sound/", "sounds/"]) {
logical = this.game.files[prefix + low];
if (logical) break;
}
}
const entry = logical ? this.manifest.files[logical] : undefined;
if (!entry || !logical) return null;
const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`;
const improved = this.liveImages?.url(logical, entry.hash, source);
const url = improved || source;
const baseScale = this.game.assetScale?.[logical] ?? 1;
this.imageScales.set(url, baseScale * (improved ? this.liveImages!.scale(improved) : 1));
return url;
}
private resolveImage(name: string[]): ResolvedImage | null {
const full = name.join(" ");
const [tag, ...attrs] = name;
const layered = this.game.images[tag];
if (layered?.layered) return this.resolveLayered(tag, layered, attrs);
if (this.game.images[full]) return { def: this.game.images[full], name };
const candidates = this.imagesByTag.get(tag) ?? [];
let best: { attrs: string[]; key: string } | null = null;
for (const c of candidates) {
if (attrs.every((a) => c.attrs.includes(a)) && (!best || c.attrs.length < best.attrs.length)) best = c;
}
if (!best && attrs.length) {
// keep attributes of the currently shown image that the new ones don't replace
const current = this.renderer.attrsOf(tag)?.slice(1) ?? [];
for (const c of candidates) {
if (attrs.every((a) => c.attrs.includes(a)) || !c.attrs.some((a) => attrs.includes(a))) continue;
const keeps = current.filter((a) => c.attrs.includes(a)).length;
if (!best || keeps > 0) best = c;
}
}
if (!best) return null;
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!;
const groupOf = (a: string) => layers.find((l) => l.attr === a)?.group ?? null;
const removed = attrs.filter((a) => a.startsWith("-")).map((a) => a.slice(1));
const added = attrs.filter((a) => !a.startsWith("-"));
const current = (this.renderer.attrsOf(tag)?.slice(1) ?? []).filter((a) => {
if (removed.includes(a)) return false;
const g = groupOf(a);
return !added.some((n) => n === a || (g !== null && groupOf(n) === g));
});
const selected = [...current, ...added];
// `group x auto`: attributes backed by images named <tag>_<group>_<attr>
const extra: Layer[] = [];
for (const l of layers) {
if (!l.autoGroup) continue;
for (const a of selected) {
const ref = `${tag}_${l.autoGroup}_${a}`;
if (this.game.images[ref] && !layers.some((x) => x.attr === a))
extra.push({ attr: a, group: l.autoGroup, img: { ref } });
}
}
return { def: { ...def, layered: [...layers, ...extra], selected }, name: [tag, ...selected] };
}
private imageFromSpec(spec: any): ResolvedImage | null {
if (spec.name) return this.resolveImage(spec.name);
if (spec.text !== undefined) return { def: { text: spec.text }, name: ["text"] };
if (spec.textExpr) return { def: { textExpr: spec.textExpr }, name: ["text"] };
if (spec.expr) {
const v = this.safeEval(spec.expr);
if (typeof v !== "string") return null;
if (v.startsWith("#")) return { def: { color: v }, name: [v] };
if (v.includes(".")) return { def: { file: v }, name: [v.split("/").pop()!.split(".")[0]] };
return this.resolveImage(v.split(" "));
}
return null;
}
// --- python environment ----------------------------------------------------------
private safeEval(e: Expr | null | undefined): any {
try {
return this.py.eval(e);
} catch (err) {
if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
console.warn("[pgvn] ошибка выражения", err);
return null;
}
}
private makePersistent(): Record<string, any> {
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
const save = () => saveJSON(this.persistentKey, encodeValue(data));
return new Proxy(data, {
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined : null),
set: (t, k: string, v) => {
t[k] = v;
save();
return true;
},
});
}
private makeBuiltins(): Record<string, any> {
const kwless = (args: any[]) =>
args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args;
const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {};
const seeded = (seed: any) => {
let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
};
const makeRandom = (source: () => number): Record<string, any> => {
let next = source;
const below = (n: number) => Math.floor(next() * n);
const self: Record<string, any> = {
randint: (a: number, b: number) => a + below(b - a + 1),
randrange: (a: number, b?: number, step = 1) => {
const [lo, hi] = b === undefined ? [0, a] : [a, b];
const count = Math.max(0, Math.ceil((hi - lo) / step));
if (!count) throw new Error("randrange: пустой диапазон");
return lo + below(count) * step;
},
random: () => next(),
choice: (xs: any[]) => {
if (!xs.length) throw new Error("choice: пустая последовательность");
return xs[below(xs.length)];
},
choices: (xs: any[], ...a: any[]) => {
const kw = kwOf(a);
const weights: number[] | null = kw.weights ?? kwless(a)[0] ?? null;
const k = kw.k ?? 1;
const total = weights ? weights.reduce((s, w) => s + w, 0) : xs.length;
const out: any[] = [];
for (let n = 0; n < k; n++) {
if (!weights) { out.push(xs[below(xs.length)]); continue; }
let roll = next() * total, i = 0;
while (i < xs.length - 1 && roll >= weights[i]) roll -= weights[i++];
out.push(xs[i]);
}
return out;
},
sample: (xs: any[], k: number) => {
const pool = [...xs];
if (k > pool.length) throw new Error("sample: выборка больше совокупности");
for (let i = 0; i < k; i++) {
const j = i + below(pool.length - i);
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, k);
},
shuffle: (xs: any[]) => {
for (let i = xs.length - 1; i > 0; i--) {
const j = below(i + 1);
[xs[i], xs[j]] = [xs[j], xs[i]];
}
},
uniform: (a: number, b: number) => a + next() * (b - a),
seed: (seed?: any) => { next = seed === undefined || seed === null ? Math.random : seeded(seed); return null; },
};
// `rng = renpy.random.Random(seed)`: an independent generator; a given seed makes it repeatable
self.Random = (seed?: any) => {
return makeRandom(seed === undefined || seed === null ? Math.random : seeded(seed));
};
return self;
};
const random = makeRandom(Math.random);
const music = (ch: string) => ({
play: (...a: any[]) => {
const kw = kwOf(a);
const files = kwless(a)[0];
this.audio.play(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String),
{ loop: kw.loop, fadein: kw.fadein, fadeout: kw.fadeout });
},
queue: (...a: any[]) => {
const kw = kwOf(a);
const files = kwless(a)[0];
this.audio.queue(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String), kw.loop);
},
stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0),
set_volume: () => {},
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,
});
const renpyImpl: Record<string, any> = {
random,
translation: { translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null) },
style: { rebuild: () => null },
// diagnostics only: nothing is written anywhere
log: () => null,
write_log: () => null,
get_filename_line: () => ["", 0],
has_image: (name: any) => this.resolveImage(pyStr(name).split(" ")) !== null,
has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]),
list_files: () => Object.keys(this.game.files ?? {}),
get_mouse_pos: () => [...this.mousePos],
jump_out_of_context: (label: string) => {
throw new JumpSignal("jump", label);
},
image_size: (name: any) => {
const text = pyStr(name);
const resolved = this.resolveImage(text.split(" "));
const url = this.fileUrl(resolved?.def?.file ?? text);
const probe = url ? new Image() : null;
if (probe) probe.src = url!;
// only a picture the browser already holds can be measured at once; a game catches the error
if (!probe || !probe.complete || !probe.naturalWidth) throw new Error(`IOError: размер ${text} недоступен`);
return [probe.naturalWidth, probe.naturalHeight];
},
music: music("music"),
sound: music("sound"),
play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}),
notify: (msg: any) => this.notify(pyStr(msg)),
jump: (label: string) => {
throw new JumpSignal("jump", label);
},
call: (label: string) => {
throw new JumpSignal("call", label);
},
// 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)),
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(" ");
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)),
showing: (tag: string) => this.renderer.isShown(String(tag).split(" ")[0]),
get_attributes: (tag: string) => this.renderer.attrsOf(tag)?.slice(1) ?? [],
full_restart: () => {
throw new JumpSignal("jump", "__main_menu__");
},
quit: () => {
throw new JumpSignal("jump", "__quit__");
},
version: () => "pgvn",
get_showing_tags: () => this.renderer.snapshot().map((image) => image.tag),
run: (action: any) => {
this.screens.run(action, null);
return null;
},
pop_call: () => {
this.stack.pop();
return null;
},
curry: (f: any) => {
const arity = f?.__def ? f.__def.params.filter((p: any) => p.kind === "p" && !p.d).length : f?.length ?? 0;
const curried = (...args: any[]): any => (args.length >= arity ? f(...args) : (...more: any[]) => curried(...args, ...more));
return curried;
},
...this.screens.renpyApi(),
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
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,
call_stack_depth: () => this.stack.length,
const: (value: any) => value,
screenshot: () => null,
Keymap: () => null,
register_shader: () => null,
register_statement: () => null,
redraw: () => null,
set_tag_attributes: () => null,
stop_predict: () => null,
map_event: () => null,
count_displayables_in_layer: () => 0,
displayable: (value: any) => value,
variant: (name: string) => ["pc", "large", "web", "medium"].includes(String(name)),
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, {
get: (t, k: string) => {
if (k in t) return t[k];
this.py.warnOnce(`renpy.${k} не поддерживается — пропущено`);
return () => null;
},
});
const num = (x: any) => (typeof x === "string" ? Number(x.trim()) : Number(x));
return {
renpy,
persistent: this.persistent,
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(),
gui: {},
store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null,
set: (_t, k: string, v) => ((this.store[k] = v), true) }),
audio: this.game.audio,
random,
math: {
floor: Math.floor, ceil: Math.ceil, sqrt: Math.sqrt, pi: Math.PI, e: Math.E, pow: Math.pow,
sin: Math.sin, cos: Math.cos, tan: Math.tan, asin: Math.asin, acos: Math.acos, atan: Math.atan,
atan2: Math.atan2, exp: Math.exp, fabs: Math.abs, trunc: Math.trunc, hypot: Math.hypot,
log: (x: number, base?: number) => (base ? Math.log(x) / Math.log(base) : Math.log(x)),
log10: Math.log10, fmod: (a: number, b: number) => a % b,
modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)],
degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180,
isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity,
copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1),
},
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
// 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(),
...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",
"Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])),
str: (x: any) => pyStr(x),
int: (x: any) => Math.trunc(num(x)),
float: (x: any) => num(x),
bool: (x: any) => truthy(x),
abs: Math.abs,
round: (x: number, n = 0) => Math.round(x * 10 ** n) / 10 ** n,
min: (...a: any[]) => (a.length === 1 ? Math.min(...a[0]) : Math.min(...a)),
max: (...a: any[]) => (a.length === 1 ? Math.max(...a[0]) : Math.max(...a)),
sum: (xs: number[]) => xs.reduce((s, x) => s + x, 0),
any: (xs: any[]) => xs.some(truthy),
all: (xs: any[]) => xs.every(truthy),
range: (a: number, b?: number, step = 1) => {
const [lo, hi] = b === undefined ? [0, a] : [a, b];
const out: number[] = [];
for (let i = lo; step > 0 ? i < hi : i > hi; i += step) out.push(i);
return out;
},
list: (xs: any = []) => [...(xs instanceof PyDict ? xs.map.keys() : xs)],
tuple: (xs: any = []) => [...xs],
set: (xs: any = []) => [...new Set(xs)],
frozenset: (xs: any = []) => [...new Set(xs)],
dir: (o?: any) => (o === undefined ? Object.keys(this.store) : Object.keys(o ?? {})),
dict: (...a: any[]) => {
const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []);
for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v);
return d;
},
sorted: (xs: any[]) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
reversed: (xs: any[]) => [...xs].reverse(),
enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]),
zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])),
_: (s: string) => s,
__: (s: string) => this.tr.str(s),
...userBuiltins(this.py),
};
}
private languagePrefs() {
const engine = this;
return {
get language() {
return engine.tr.renpyName;
},
get_mute: () => false,
get_volume: () => 1,
};
}
private async setLanguage(lang: string) {
try {
this.tr = await this.loadLocale(lang);
rememberLanguage(lang);
this.applyLocaleFont();
this.notify(languageName(this.game, lang));
} catch (err) {
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
}
}
/** 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;
}
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();
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 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),
text: (el, value, size) => renderText(el, parseText(this.interp(this.tr.str(value))), Infinity, size),
fontFamily: (file) => this.screenFont(file),
get baseSize() { return engine.baseSize; },
play: (channel, file, loop) => this.audio.play(channel, [file], { loop }),
stop: (channel) => this.audio.stop(channel, 0),
notify: (message) => this.notify(message),
jump: (label) => this.restart(() => this.guardedJump(label)),
call: (label) => this.restart(() => this.call(label)),
quit: () => this.onExit(),
mainMenu: () => this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
openMenu: () => this.openGameMenu(),
openUrl: (url) => {
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"),
slotInfo: (slot) => {
if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); }
const meta = this.slotCache.get(slot);
return meta ? { title: meta.title, time: meta.updated_at } : null;
},
saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm),
loadSlot: (slot) => void this.load(slot),
deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm),
get filePage() { return String(engine.persistent._file_page ?? "1"); },
set filePage(page: string) { engine.persistent._file_page = page; engine.screens.refresh(); },
transform: (name) => this.game.transforms[name] ?? null,
atl: (name) => this.game.atl?.[name] ?? null,
};
}
/** A jump requested from a widget; the special menu/quit targets end the session instead. */
private guardedJump(label: string) {
try {
this.jump(label);
} catch (err) {
if (!(err instanceof Restart)) throw err;
this.cur = { label: "", path: [0] };
if (err.message === "__quit__") this.onExit();
}
}
private screenImage(value: string): HTMLElement | null {
if (/^#[0-9a-f]{3,8}$/i.test(value)) {
const box = el("div");
box.style.background = value;
box.style.width = box.style.height = "100%";
return box;
}
const resolved = this.resolveImage(value.split(" "));
if (resolved) return this.renderer.displayable(resolved.def);
const url = this.fileUrl(value);
if (!url) return null;
return this.renderer.displayable({ file: value });
}
private screenFont(file: string): string | null {
let family = this.screenFonts.get(file);
if (!family) {
family = `pg-scr-${this.screenFonts.size}`;
this.screenFonts.set(file, family);
this.loadFont(family, file);
}
return family;
}
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);
} catch (err) {
console.warn("[pgvn] ошибка init", err);
}
}
private newStore() {
this.resetStore();
for (const [name, e] of this.game.defaults) {
if (!(name in this.store)) this.store[name] = this.safeEval(e);
}
}
// --- control flow ------------------------------------------------------------------
private blockFor(frame: Frame): Node[] {
let block = this.labels.get(frame.label)!.label.body;
for (let k = 0; k < frame.path.length - 1; k += 2) {
block = childBlock(block[frame.path[k]], frame.path[k + 1]);
}
return block;
}
private node(): Node | null {
if (!this.labels.has(this.cur.label)) return null;
return this.blockFor(this.cur)[this.cur.path[this.cur.path.length - 1]] ?? null;
}
/** After a path change, move past ended blocks (falling through labels / looping whiles). */
private settle() {
for (let guard = 0; guard < 10000; guard++) {
const p = this.cur.path;
if (!this.labels.has(this.cur.label)) return;
const block = this.blockFor(this.cur);
if (p[p.length - 1] < block.length) return;
if (p.length === 1) {
const ref = this.labels.get(this.cur.label)!;
const next = ref.module.labels[ref.index + 1];
if (next) {
this.cur = { label: next.name, path: [0] };
continue;
}
this.doReturn();
return;
}
const parent = p.slice(0, -2);
const parentNode = this.blockFor({ label: this.cur.label, path: parent })[parent[parent.length - 1]];
this.cur.path = parent;
if (parentNode.op !== "while") this.cur.path[parent.length - 1]++;
}
}
private advance() {
this.cur.path[this.cur.path.length - 1]++;
this.settle();
}
private enter(branch: number) {
this.cur.path.push(branch, 0);
this.settle();
}
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);
}
let name = target;
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) {
throw new Error(`Метка «${target}» не найдена`);
}
const params = anchor ? anchor.params : this.labels.get(name)!.label.params;
if (params?.length) this.bindParams(params, args, frame);
this.cur = anchor ? { label: anchor.label, path: [...anchor.path] } : { label: name, path: [0] };
this.markSeen(name);
this.settle();
}
/** Label parameters are dynamically scoped: a call restores old values on return. */
private bindParams(params: [string, Expr | null][], args?: { a: any[]; kw: Record<string, any> },
frame?: Frame) {
const values: Record<string, any> = {};
params.forEach(([p, def], i) => {
if (args && i < args.a.length) values[p] = args.a[i];
else if (args && p in args.kw) values[p] = args.kw[p];
else values[p] = def ? this.safeEval(def) : null;
});
if (frame) {
frame.saved = {};
for (const p of Object.keys(values)) frame.saved[p] = { had: p in this.store, value: this.store[p] };
}
Object.assign(this.store, values);
}
private call(target: string, argExprs?: { a: Expr[]; kw: Record<string, Expr> }) {
// arguments are evaluated in the caller's scope, before parameters are bound
const args = argExprs && {
a: argExprs.a.map((e) => this.safeEval(e)),
kw: Object.fromEntries(Object.entries(argExprs.kw).map(([k, e]) => [k, this.safeEval(e)])),
};
const frame: Frame = { label: this.cur.label, path: [...this.cur.path] };
this.stack.push(frame);
this.jump(target, args, frame);
}
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;
}
for (const [p, old] of Object.entries(frame.saved ?? {})) {
if (old.had) this.store[p] = old.value;
else delete this.store[p];
}
this.cur = { label: frame.label, path: frame.path };
this.advance();
}
private markSeen(label: string) {
const seen: string[] = this.persistent._seen ?? [];
if (!seen.includes(label)) this.persistent._seen = [...seen, label];
}
// --- main loop --------------------------------------------------------------------
private wait<T = void>(setup: (done: (v: T) => void) => void): Promise<T> {
const gen = this.gen;
return new Promise<T>((resolve, reject) => {
const wake = () => reject(new Restart());
this.waiters.push(wake);
setup((v) => {
this.waiters = this.waiters.filter((w) => w !== wake);
if (gen === this.gen) resolve(v);
else reject(new Restart());
});
});
}
private wakeAll() {
const ws = this.waiters;
this.waiters = [];
ws.forEach((w) => w());
}
private async loop() {
const gen = this.gen;
this.running = true;
try {
while (gen === this.gen) {
if (!this.cur.label) {
this.showMainMenu();
return;
}
const node = this.node();
if (!node) {
this.settle();
if (!this.node()) {
this.cur = { label: "", path: [0] };
}
continue;
}
try {
await this.exec(node);
this.screens.refresh();
} 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;
}
}
} catch (err) {
if (err instanceof Restart) {
if (err.message === "__main_menu__") this.showMainMenu();
if (err.message === "__quit__") this.onExit();
return;
}
this.fail(err);
} finally {
if (gen === this.gen) this.running = false;
}
}
private screenArgs(node: Node) {
return {
args: (node.args ?? []).map((e: Expr) => this.safeEval(e)),
kw: Object.fromEntries(Object.entries(node.kw ?? {}).map(([k, e]) => [k, this.safeEval(e as Expr)])),
};
}
private async exec(node: Node) {
switch (node.op) {
case "nani_exit":
this.stack = [];
this.cur = { label: "", path: [0] };
return;
case "nani_add_choice":
(this.store.__nani_choices ??= []).push({ text: node.what, tid: node.tid,
target: node.target, handler: node.handler });
this.advance();
return;
case "nani_clear_choice":
this.store.__nani_choices = (this.store.__nani_choices ?? []).filter(
(choice: any) => node.handler !== "*" && choice.handler !== node.handler);
this.advance();
return;
case "nani_menu": {
const choices: { text: string; tid: string; target: string }[] = this.store.__nani_choices ?? [];
if (!choices.length) throw new Error("Naninovel: Stop без доступных вариантов выбора");
this.interactionStart();
this.setSkip(false);
const target = await this.wait<string>((done) => {
this.menuEl.textContent = "";
for (const choice of choices) {
const button = el("button", "pg-choice");
renderText(button, parseText(this.interp(this.tr.line(choice.tid, choice.text))), Infinity, this.baseSize);
button.onclick = (event) => { event.stopPropagation(); done(choice.target); };
this.menuEl.append(button);
}
this.menuEl.classList.remove("pg-hidden");
});
this.menuEl.classList.add("pg-hidden");
this.store.__nani_choices = [];
this.jump(target);
return;
}
case "nani_hide_characters":
this.renderer.commit();
for (const image of this.renderer.snapshot()) {
if (image.tag.startsWith("nani-char-")) this.renderer.hide(image.tag);
}
if (node.duration) await this.renderer.transition({ t: "dissolve", d: node.duration });
this.advance();
return;
case "say":
await this.say(node);
this.advance();
return;
case "scene":
case "show":
case "hide": {
if (node.with) this.renderer.commit();
if (node.op === "scene") {
this.renderer.scene();
this.clearNvl();
}
if (node.op === "hide") this.renderer.hide(node.tagExpr ? pyStr(this.safeEval(node.tagExpr)).split(" ")[0] : node.tag);
else if (node.img) this.showNode(node);
if (node.with) await this.renderer.transition(this.transition(node.with));
this.advance();
return;
}
case "with":
await this.renderer.transition(this.transition(node.t));
this.advance();
return;
case "play":
case "queue": {
const files = node.files.map((f: Expr) => this.audioFile(f)).filter(Boolean) as string[];
if (files.length) {
if (node.op === "play")
this.audio.play(node.ch, files, { loop: node.loop, fadein: node.fadein, fadeout: node.fadeout,
ifChanged: node.ifChanged });
else this.audio.queue(node.ch, files, node.loop);
}
this.advance();
return;
}
case "stop":
this.audio.stop(node.ch, node.fadeout ?? 0);
this.advance();
return;
case "voice":
if (node.file) {
const f = this.audioFile(node.file);
if (f) this.audio.play("voice", [f], { loop: false });
}
this.advance();
return;
case "pause": {
const d = node.d ? Number(this.safeEval(node.d)) : null;
await this.pause(d);
this.advance();
return;
}
case "menu":
return this.menu(node);
case "if": {
const i = node.branches.findIndex((b: any) => b.c === null || truthy(this.safeEval(b.c)));
if (i >= 0) this.enter(i);
else this.advance();
return;
}
case "while":
if (truthy(this.safeEval(node.c))) this.enter(0);
else this.advance();
return;
case "jump":
return this.jump(node.target ?? pyStr(this.safeEval(node.expr)));
case "call":
return this.call(node.target ?? pyStr(this.safeEval(node.expr)), node.args);
case "call_screen": {
const { args, kw } = this.screenArgs(node);
this.interactionStart();
this.setSkip(false);
const value = await this.wait<any>((done) => this.screens.show(node.name, args, kw, { call: true, onReturn: done }));
this.screens.hideCall(node.name);
this.store._return = value ?? null;
this.advance();
return;
}
case "show_screen": {
const { args, kw } = this.screenArgs(node);
this.screens.show(node.name, args, kw);
this.advance();
return;
}
case "hide_screen":
this.screens.hide(node.name);
this.advance();
return;
case "camera":
this.renderer.camera(node.at ?? []);
this.advance();
return;
case "return":
this.doReturn();
return;
case "exec":
try {
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;
throw err;
}
this.advance();
return;
case "window":
this.windowShown = node.mode !== "hide";
if (!this.windowShown) this.textbox.classList.add("pg-hidden");
this.advance();
return;
case "nvl":
if (node.a === "clear") this.clearNvl();
else this.nvlEl.classList.toggle("pg-hidden", node.a === "hide");
this.advance();
return;
default:
this.advance();
}
}
private showNode(node: Node) {
const img = this.imageFromSpec(node.img);
if (!img) {
console.warn("[pgvn] нет изображения", node.img);
return;
}
const tag = node.as ?? (node.img.name ? node.img.name[0] : img.name[0]);
for (let n = 1; n <= img.name.length; n++) this.seenImages.add(img.name.slice(0, n).join(" "));
const zorder = node.zorder ? Number(this.safeEval(node.zorder)) : undefined;
this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind });
}
private transition(t: any) {
if (t && t.t === "ref") return this.game.transitions[t.name] ?? { t: "dissolve", d: 0.5 };
return t;
}
private audioFile(e: Expr): string | null {
if (e.k === "name" && this.game.audio[e.id] && !(e.id in this.store)) return this.game.audio[e.id];
const v = this.safeEval(e);
if (typeof v !== "string") return null;
return this.game.audio[v] ?? v;
}
// --- interactions -----------------------------------------------------------------
private interactionStart() {
this.renderer.commit();
this.interactions++;
if (this.interactions % 15 === 0) void this.save("auto", true);
this.preloadAhead();
}
private preloadAhead() {
const block = this.labels.has(this.cur.label) ? this.blockFor(this.cur) : [];
const start = this.cur.path[this.cur.path.length - 1];
for (const n of block.slice(start + 1, start + 12)) {
if ((n.op === "show" || n.op === "scene") && n.img?.name) this.renderer.preload(this.resolveImage(n.img.name)?.def ?? null);
}
}
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);
who = typeof value === "string" ? value : undefined;
}
if (this.game.engine === "naninovel" && node.whoStr !== undefined) {
const entry = this.game.naniResources?.CharacterNames?.[node.whoStr]?.[0];
return { char: {}, name: this.interp(entry ? this.tr.line(entry.tid, entry.text) : this.tr.str(node.whoStr)), mode: "adv" };
}
if (node.whoStr !== undefined) return { char: {}, name: this.interp(this.tr.str(node.whoStr)), mode: "adv" };
if (!who || who === "narrator") return { char: {}, name: "", mode: "adv" };
if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" };
if (who === "extend") return { char: this.lastSay?.char ?? {}, name: this.lastSay?.name ?? "", mode: "extend" };
if (who === "nvl") return { char: { nvl: true }, name: "", mode: "nvl" };
const char = this.game.characters[who];
if (!char) {
const v = this.store[who];
if (v && typeof v === "object" && v.__character) {
return { char: { color: v.color ?? undefined }, name: this.interp(this.tr.str(v.name ?? who)), mode: "adv" };
}
return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" };
}
let name = this.tr.str(char.name ?? "");
if (char.nameExpr) name = this.tr.str(pyStr(this.safeEval(char.nameExpr)));
name = this.interp(name);
return { char, name: `${char.whoPrefix ?? ""}${name}${char.whoSuffix ?? ""}`, mode: char.nvl ? "nvl" : "adv" };
}
private interp(s: string): string {
if (this.game.engine === "naninovel") return naniText(s, this.game.naniExpressions ?? {},
(name) => Object.hasOwn(this.store, name) ? this.store[name] : undefined);
return interpolate(s, (expr) => this.lookupPath(expr), (v) => this.tr.str(v));
}
private lookupPath(expr: string): any {
const m = expr.match(/^[A-Za-z_][\w]*/);
if (!m) return "";
let v = this.py.lookup(m[0]);
const rest = expr.slice(m[0].length);
for (const part of rest.matchAll(/\.(\w+)|\[(\d+|'[^']*'|"[^"]*")\]/g)) {
if (v === null || v === undefined) return "";
const k = part[1] ?? JSON.parse(part[2].replace(/^'|'$/g, '"'));
v = v instanceof PyDict ? v.get(k) : v[k];
}
return v ?? "";
}
private async say(node: Node, inMenu = false) {
const { char, name, mode } = this.characterFor(node);
if (node.attrs && char.image && this.renderer.isShown(char.image)) {
const img = this.resolveImage([char.image, ...node.attrs]);
if (img) this.renderer.show(img, char.image, []);
}
if (!inMenu) this.interactionStart();
if (node.with) await this.renderer.transition(this.transition(node.with));
let raw = node.whatExpr
? this.interp(pyStr(this.safeEval(node.whatExpr)))
: this.interp(this.tr.line(node.tid, node.what));
raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
let prefixLen = 0;
if (mode === "extend" && this.lastSay) {
prefixLen = [...plainText(parseText(this.lastSay.text))].length;
raw = this.lastSay.text + raw;
}
const parsed = parseText(raw);
this.lastSay = { name, text: raw, color: char.color, char };
if (mode !== "extend") this.history.push({ name, text: plainText(parsed) });
else if (this.history.length) this.history[this.history.length - 1].text = plainText(parsed);
if (this.history.length > 300) this.history.shift();
let target: HTMLElement;
if (mode === "centered") {
this.textbox.classList.add("pg-hidden");
this.centeredEl.classList.remove("pg-hidden");
target = this.centeredEl;
} else if (mode === "nvl") {
this.textbox.classList.add("pg-hidden");
this.nvlEl.classList.remove("pg-hidden");
const line = el("div", "pg-nvl-line");
if (name) {
const who = el("span", "pg-nvl-name", name);
if (char.color) who.style.color = char.color;
line.append(who);
}
const what = el("span", "pg-nvl-what");
line.append(what);
this.nvlEl.append(line);
this.nvlLines.push(line);
target = what;
} else {
this.centeredEl.classList.add("pg-hidden");
this.nvlEl.classList.add("pg-hidden");
this.textbox.classList.remove("pg-hidden");
this.namebox.textContent = name;
this.namebox.classList.toggle("pg-hidden", !name);
this.namebox.style.color = char.color ?? "";
this.textEl.style.color = char.whatColor ?? "";
this.textEl.style.fontStyle = char.whatItalic ? "italic" : "";
this.textEl.style.fontWeight = char.whatBold ? "bold" : "";
target = this.textEl;
}
await this.typewrite(target, parsed, Math.max(prefixLen, fastPoint(parsed)), inMenu);
if (inMenu || node.interact === false) return;
if (!parsed.noWait) await this.waitAdvance(parsed.length);
this.audio.stop("voice");
this.centeredEl.classList.add("pg-hidden");
}
private async typewrite(target: HTMLElement, parsed: ReturnType<typeof parseText>, from: number, instant = false) {
this.ctc.remove();
const total = parsed.length;
const waits = waitPoints(parsed).filter((w) => w.at > from);
let shown = from;
const cps = this.settings.cps;
if (instant || cps <= 0 || this.skip) {
renderText(target, parsed, total, this.baseSize);
target.append(this.ctc);
return;
}
let completed = false;
const finishClick = () => {
completed = true;
};
this.onAdvance = finishClick;
try {
for (const stop of [...waits.map((w) => w.at), total]) {
let last = performance.now();
while (shown < stop && !completed && !this.skip) {
await this.frame();
const now = performance.now();
shown = Math.min(stop, shown + ((now - last) / 1000) * cps);
last = now;
renderText(target, parsed, Math.floor(shown), this.baseSize);
}
shown = completed || this.skip ? total : stop;
renderText(target, parsed, Math.floor(shown), this.baseSize);
if (completed || this.skip) break;
const w = waits.find((x) => x.at === stop);
if (w) {
if (w.seconds !== null) await this.pause(w.seconds);
else await this.waitAdvance(0);
this.onAdvance = finishClick;
}
}
} finally {
this.onAdvance = null;
}
target.append(this.ctc);
}
private frame(): Promise<void> {
return this.wait((done) => requestAnimationFrame(() => done()));
}
private onAdvance: (() => void) | null = null;
/** Wait for the player to advance (click/space), or skip/auto. */
private waitAdvance(chars: number): Promise<void> {
return this.wait((done) => {
let timer = 0;
const finish = () => {
clearTimeout(timer);
this.onAdvance = null;
this.skipTick = null;
done();
};
this.onAdvance = finish;
if (this.skip) timer = window.setTimeout(finish, 40);
else if (this.auto) timer = window.setTimeout(finish, (1 + chars / 20) * this.settings.autoDelay * 1000);
this.skipTick = () => {
clearTimeout(timer);
if (this.skip) timer = window.setTimeout(finish, 40);
else if (this.auto) timer = window.setTimeout(finish, 1000 * this.settings.autoDelay);
};
});
}
private skipTick: (() => void) | null = null;
private pause(seconds: number | null): Promise<void> {
if (seconds === null) {
this.interactionStart();
return this.waitAdvance(0);
}
if (this.skip) return Promise.resolve();
return this.wait((done) => {
const timer = window.setTimeout(() => {
this.onAdvance = null;
done();
}, seconds * 1000);
this.onAdvance = () => {
clearTimeout(timer);
this.onAdvance = null;
done();
};
});
}
private async menu(node: Node) {
this.interactionStart();
const chosen: string[] = node.set ? this.safeEval(node.set) ?? [] : [];
if (node.say) await this.say(node.say, true);
else if (!this.windowShown) this.textbox.classList.add("pg-hidden");
const items = node.items
.map((item: any, i: number) => ({ ...item, i }))
.filter((item: any) => (item.if === null || truthy(this.safeEval(item.if))) && !chosen.includes(item.text));
if (!items.length) {
this.advance();
return;
}
this.setSkip(false);
const index = await this.wait<number>((done) => {
this.menuEl.textContent = "";
for (const item of items) {
const b = el("button", "pg-choice");
renderText(b, parseText(this.interp(this.tr.str(item.text))), Infinity, this.baseSize);
b.onclick = (ev) => {
ev.stopPropagation();
done(item.i);
};
this.menuEl.append(b);
}
this.menuEl.classList.remove("pg-hidden");
});
this.menuEl.classList.add("pg-hidden");
if (Array.isArray(chosen) && node.set) chosen.push(node.items[index].text);
this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(this.tr.str(node.items[index].text))))}` });
this.enter(index);
}
private clearNvl() {
this.nvlLines.forEach((l) => l.remove());
this.nvlLines = [];
this.nvlEl.classList.add("pg-hidden");
}
private notify(msg: string) {
this.notifyEl.textContent = msg;
this.notifyEl.classList.remove("pg-hidden");
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) {
this.gen++;
this.wakeAll();
this.onAdvance = null;
this.skipTick = null;
this.screens.hideCalls();
this.closeScreen();
this.menuEl.classList.add("pg-hidden");
this.centeredEl.classList.add("pg-hidden");
this.clearNvl();
this.setSkip(false);
setup();
void this.loop();
}
start() {
this.showMainMenu();
}
/** Preview from a given line (translation editor): game variables keep their defaults. */
startAt(nodeId: string) {
const at = this.findNode(nodeId);
if (!at) {
this.start();
this.notify("Строка не найдена в этой версии");
return;
}
this.newGame(at);
// rebuild the picture: replay the visual statements of the label that come before the line
const body = this.labels.get(at.label)!.label.body;
for (const n of body.slice(0, at.path[0])) {
if (n.op === "scene") {
this.renderer.scene();
if (n.img) this.showNode(n);
} else if (n.op === "show") this.showNode(n);
else if (n.op === "hide") this.renderer.hide(n.tag);
}
this.renderer.commit();
this.notify("Предпросмотр с выбранной строки — переменные игры по умолчанию");
}
private findNode(id: string): Frame | null {
const visit = (nodes: Node[], label: string, prefix: number[]): Frame | null => {
for (let i = 0; i < nodes.length; i++) {
const n = nodes[i];
const path = [...prefix, i];
if (n.id === id || (n.op === "menu" && n.say?.id === id)) return { label, path };
const children: [Node[], number][] =
n.op === "if" ? n.branches.map((b: any, k: number) => [b.body, k])
: n.op === "menu" ? n.items.map((it: any, k: number) => [it.body, k])
: n.op === "while" ? [[n.body, 0]] : [];
for (const [body, k] of children) {
const hit = visit(body, label, [...path, k]);
if (hit) return hit;
}
}
return null;
};
for (const { label } of this.labels.values()) {
const hit = visit(label.body, label.name, []);
if (hit) return hit;
}
return null;
}
private newGame(at?: Frame) {
this.restart(() => {
this.audio.stopAll();
this.renderer.scene();
this.renderer.camera([]);
this.renderer.commit();
this.newStore();
this.stack = [];
this.history = [];
this.lastSay = null;
this.textbox.classList.add("pg-hidden");
this.cur = at ?? { label: this.game.entry, path: [0] };
this.markSeen(this.cur.label);
this.settle();
});
}
private snapshotState(): SaveData {
const anchor = (f: Frame): Frame => {
const label = this.labels.get(f.label)?.label;
let body = label?.body;
for (let i = 0; body && i < f.path.length - 1; i += 2) {
const n = body[f.path[i]];
if (!n) { body = undefined; break; }
body = n.op === "if" ? n.branches[f.path[i + 1]]?.body
: n.op === "menu" ? n.items[f.path[i + 1]]?.body : n.body;
}
return { ...f, path: [...f.path], nodeId: body?.[f.path[f.path.length - 1]]?.id };
};
return {
v: 1,
revision: this.manifest.revision,
cur: anchor(this.cur),
stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })),
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,
text: this.lastSay ? `${this.lastSay.name ? this.lastSay.name + ": " : ""}${plainText(parseText(this.lastSay.text))}` : "",
};
}
private slotCache = new Map<string, { title: string; updated_at: string }>();
private slotsRead = false;
/** Slot metadata for the File* actions of a game's own save screens (read once, then kept up to date). */
private async refreshSlots() {
try {
this.slotCache = new Map((await this.saves.list()).map((meta) => [meta.slot, meta]));
} catch { /* storage unavailable: slots look empty */ }
this.screens.refresh();
}
private async saveFileSlot(slot: string, confirm: boolean) {
if (confirm && this.slotCache.has(slot) && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
await this.save(slot);
await this.refreshSlots();
}
private async deleteFileSlot(slot: string, confirm: boolean) {
if (!this.slotCache.has(slot)) return;
if (confirm && !(await this.confirmAsk("Удалить сохранение?", "Удалить"))) return;
await this.saves.remove(slot).catch(() => {});
await this.refreshSlots();
}
private async save(slot: string, silent = false) {
if (!this.running || !this.cur.label) return;
const data = this.snapshotState();
const title = (data.text ?? "").slice(0, 120) || this.cur.label;
try {
await this.saves.put(slot, title, data);
if (!silent) this.notify("Сохранено");
} catch (err) {
if (!silent) this.notify(`Не удалось сохранить: ${(err as Error).message}`);
}
}
private async load(slot: string) {
const data = await this.saves.get(slot);
if (!data) {
this.notify("Сохранение не найдено");
return;
}
if (!this.labels.has(data.cur.label)) {
this.notify("Сохранение не подходит к этой версии игры");
return;
}
if (data.revision && data.revision !== this.manifest.revision) {
// IDs repair positions, but cannot prove compatibility of variables/game logic.
const frames = [data.cur, ...data.stack];
const mapped = frames.map((f) => f.nodeId ? this.findNode(f.nodeId) : null);
if (mapped.some((f) => !f)) {
this.notify("После изменения игры позиция сохранения не найдена. Используйте прежнюю ревизию или начните заново");
return;
}
if (!(await this.confirmAsk("Игра изменилась. Позиции найдены по ID, но состояние переменных может быть несовместимо. Попробовать загрузить?", "Загрузить"))) return;
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();
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();
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(() => {
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;
this.quick?.querySelector("[data-act=skip]")?.classList.toggle("pg-on", on);
this.skipTick?.();
}
private setAuto(on: boolean) {
this.auto = on;
this.quick.querySelector("[data-act=auto]")?.classList.toggle("pg-on", on);
this.skipTick?.();
}
// --- UI -------------------------------------------------------------------------
private buildUI() {
const { ui } = this.game;
const [w, h] = this.game.resolution;
const stage = this.renderer.stage;
const px = (v: any, total: number, fallback: number) =>
typeof v === "number" ? (Number.isInteger(v) && v > 1 ? v : v * total) : fallback;
stage.style.setProperty("--pg-font", `${this.baseSize}px`);
stage.style.setProperty("--pg-name-font", `${Number(ui.name_text_size) || Math.round(this.baseSize * 1.2)}px`);
if (ui.accent_color) stage.style.setProperty("--pg-accent", ui.accent_color);
if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color);
this.loadFont("pg-game-font", ui.text_font);
this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font);
this.applyLocaleFont();
this.textbox = el("div", "pg-textbox pg-hidden");
const boxH = px(ui.textbox_height, h, Math.round(h * 0.257));
this.textbox.style.height = `${boxH}px`;
if (ui.textbox) {
const url = this.fileUrl(ui.textbox);
if (url) this.textbox.style.backgroundImage = `url("${url}")`;
this.textbox.classList.add("pg-textbox-image");
}
this.namebox = el("div", "pg-namebox");
this.namebox.style.left = `${px(ui.name_xpos, w, Math.round(w * 0.1875))}px`;
this.namebox.style.transform = "translateX(-50%)";
if (!ui.name_xpos) this.namebox.style.transform = "";
this.textEl = el("div", "pg-text");
this.textEl.style.left = `${px(ui.dialogue_xpos, w, Math.round(w * 0.21))}px`;
this.textEl.style.top = `${px(ui.dialogue_ypos, boxH, Math.round(boxH * 0.18))}px`;
this.textEl.style.width = `${px(ui.dialogue_width, w, Math.round(w * 0.58))}px`;
this.ctc = el("span", "pg-ctc", "▼");
this.textbox.append(this.namebox, this.textEl);
this.quick = el("div", "pg-quick");
const actions: [string, string, () => void][] = [
["history", "История", () => this.openHistory()],
["skip", "Пропуск", () => this.setSkip(!this.skip)],
["auto", "Авто", () => this.setAuto(!this.auto)],
["save", "Сохр.", () => this.openSaves("save")],
["load", "Загр.", () => this.openSaves("load")],
["qsave", "Б.Сохр.", () => void this.save("quick")],
["qload", "Б.Загр.", () => void this.load("quick")],
["prefs", "Настройки", () => this.openSettings()],
["menu", "Меню", () => this.openGameMenu()],
];
for (const [act, label, fn] of actions) {
const b = el("button", "", label);
b.dataset.act = act;
b.onclick = (ev) => {
ev.stopPropagation();
fn();
};
this.quick.append(b);
}
this.textbox.append(this.quick);
this.menuEl = el("div", "pg-menu pg-hidden");
this.nvlEl = el("div", "pg-nvl pg-hidden");
if (ui.nvl) {
const url = this.fileUrl(ui.nvl);
if (url) this.nvlEl.style.backgroundImage = `url("${url}")`;
}
this.centeredEl = el("div", "pg-centered pg-hidden");
this.screen = el("div", "pg-screen pg-hidden");
this.notifyEl = el("div", "pg-notify pg-hidden");
stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screens.layer, this.screen, this.notifyEl);
// touch-friendly menu button outside the scaled stage
const hud = el("button", "pg-hud-btn", "☰");
hud.title = "Меню";
hud.setAttribute("aria-label", "Меню");
hud.onclick = (ev) => {
ev.stopPropagation();
if (this.screen.classList.contains("pg-main")) return;
if (this.screen.classList.contains("pg-hidden")) this.openGameMenu();
else this.dismissScreen();
};
this.container.append(hud);
stage.addEventListener("click", (ev) => {
if (!this.screen.classList.contains("pg-hidden")) return;
if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick, .pg-sbtn, .pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-smodal")) return;
this.advanceInput();
});
stage.addEventListener("wheel", (ev) => {
if (ev.deltaY < 0 && this.screen.classList.contains("pg-hidden") && this.running) this.openHistory();
}, { passive: true });
}
/** A translation may bring its own font when the game's fonts lack its glyphs. */
private applyLocaleFont() {
this.renderer.stage.classList.remove("pg-locale-font");
if (this.tr.font) this.loadFont("pg-locale-font", this.tr.font);
}
private loadFont(family: string, file?: string) {
const url = file ? this.fileUrl(file) : null;
if (!url) return;
const face = new FontFace(family, `url("${url}")`);
face.load().then((f) => {
document.fonts.add(f);
this.renderer.stage.classList.add(family);
}).catch(() => {});
}
/** In-player confirmation (the native confirm() breaks fullscreen and gamepads). */
private confirmAsk(text: string, okLabel = "Ок"): Promise<boolean> {
return new Promise((resolve) => {
const back = el("div", "pg-dialog-back");
const box = el("div", "pg-dialog");
box.setAttribute("role", "dialog");
box.append(el("p", "", text));
const row = el("div", "pg-dialog-row");
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopImmediatePropagation();
done(false);
};
const done = (v: boolean) => {
window.removeEventListener("keydown", onKey, true);
back.remove();
resolve(v);
};
const no = el("button", "", "Отмена");
no.onclick = () => done(false);
const yes = el("button", "pg-primary", okLabel);
yes.onclick = () => done(true);
window.addEventListener("keydown", onKey, true);
row.append(no, yes);
box.append(row);
back.append(box);
this.container.append(back);
yes.focus();
});
}
private advanceInput() {
if (this.skip) {
this.setSkip(false);
return;
}
if (!this.textbox.classList.contains("pg-ui-hidden")) this.onAdvance?.();
this.textbox.classList.remove("pg-ui-hidden");
}
private onKey = (ev: KeyboardEvent) => {
const screenOpen = !this.screen.classList.contains("pg-hidden");
if (ev.key === "Escape") {
if (this.screen.classList.contains("pg-main")) return;
if (screenOpen) this.dismissScreen();
else if (this.running) this.openGameMenu();
return;
}
if ((ev.target as HTMLElement)?.tagName === "INPUT") return;
if (screenOpen) return;
if (ev.key === " " || ev.key === "Enter") {
ev.preventDefault();
this.advanceInput();
} else if (ev.key === "Control" && !this.skip) {
this.setSkip(true);
} else if (ev.key === "Tab") {
ev.preventDefault();
this.setSkip(!this.skip);
} else if (ev.key === "a" || ev.key === "ф") {
this.setAuto(!this.auto);
} else if (ev.key === "h" || ev.key === "р") {
this.textbox.classList.toggle("pg-ui-hidden");
}
};
private onKeyUp = (ev: KeyboardEvent) => {
if (ev.key === "Control") this.setSkip(false);
};
private screenOnClose: (() => void) | null = null;
private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void,
unscaled = false) {
this.placeScreen(unscaled);
this.screen.textContent = "";
this.screen.classList.remove("pg-main");
this.container.classList.remove("pg-in-main");
this.screen.style.backgroundImage = "";
this.screenOnClose = onClose ?? null;
const head = el("div", "pg-screen-head");
head.append(el("h2", "", title));
const close = el("button", "pg-close", "✕");
close.setAttribute("aria-label", "Закрыть");
close.onclick = () => this.dismissScreen();
head.append(close);
const body = el("div", "pg-screen-body");
this.screen.append(head, body);
build(body);
this.screen.classList.remove("pg-hidden");
}
private dismissScreen() {
const fn = this.screenOnClose;
if (fn) fn();
else this.closeScreen();
}
private closeScreen() {
this.screenOnClose = null;
this.screen.classList.add("pg-hidden");
this.screen.classList.remove("pg-main");
this.container.classList.remove("pg-in-main");
this.screen.style.backgroundImage = "";
this.placeScreen(false);
}
private placeScreen(unscaled: boolean) {
this.screen.classList.toggle("pg-settings-screen", unscaled);
(unscaled ? this.container : this.renderer.stage).append(this.screen);
}
private showMainMenu() {
this.placeScreen(false);
this.running = false;
this.gen++;
this.wakeAll();
this.audio.stopAll();
this.textbox.classList.add("pg-hidden");
this.menuEl.classList.add("pg-hidden");
this.clearNvl();
this.screen.textContent = "";
this.screen.classList.add("pg-main");
this.container.classList.add("pg-in-main");
const bg = this.game.ui.mainMenu ? this.fileUrl(this.game.ui.mainMenu) : null;
if (bg) this.screen.style.backgroundImage = `url("${bg}")`;
const panel = el("div", "pg-main-panel");
panel.append(el("h1", "", this.game.title ?? "Новелла"));
const btn = (label: string, fn: () => void) => {
const b = el("button", "", label);
b.onclick = fn;
panel.append(b);
return b;
};
btn("Начать", () => this.newGame());
const cont = btn("Продолжить", () => void this.load("auto"));
cont.style.display = "none";
this.saves.list().then((list) => {
if (list.some((s) => s.slot === "auto")) cont.style.display = "";
}).catch(() => {});
btn("Загрузить", () => this.openSaves("load", true));
btn("Настройки", () => this.openSettings(true));
const langs = this.languages();
if (langs.length > 1) {
const select = this.languageSelect(langs, () => this.showMainMenu());
select.classList.add("pg-lang");
panel.append(select);
}
btn("В библиотеку", () => this.onExit());
this.screen.append(panel);
this.screen.classList.remove("pg-hidden");
}
private languages(): string[] {
return [this.game.sourceLang ?? "und", ...Object.keys(this.game.locales ?? {})];
}
private languageSelect(langs: string[], after: () => void): HTMLSelectElement {
const select = el("select", "pg-select");
for (const lang of langs) {
const o = el("option", "", languageName(this.game, lang) + progressLabel(this.game.locales?.[lang]?.progress));
o.value = lang;
o.selected = lang === this.tr.lang;
select.append(o);
}
select.onchange = () => void this.setLanguage(select.value).then(after);
return select;
}
private openGameMenu() {
this.openScreen("Меню", (body) => {
const list = el("div", "pg-menu-list");
const add = (label: string, fn: () => void) => {
const b = el("button", "", label);
b.onclick = fn;
list.append(b);
};
add("Вернуться", () => this.closeScreen());
add("История", () => this.openHistory());
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
add("Сохранить", () => this.openSaves("save"));
add("Загрузить", () => this.openSaves("load"));
add("Настройки", () => this.openSettings());
add("Главное меню", () => {
void this.confirmAsk("Выйти в главное меню? Несохранённый прогресс будет потерян.", "Выйти")
.then((yes) => yes && this.showMainMenu());
});
add("В библиотеку", () => this.onExit());
body.append(list);
});
}
private openHistory() {
this.openScreen("История", (body) => {
const list = el("div", "pg-history");
for (const h of this.history) {
const row = el("div", "pg-history-row");
if (h.name) row.append(el("b", "", h.name));
row.append(el("span", "", h.text));
list.append(row);
}
body.append(list);
requestAnimationFrame(() => (list.scrollTop = list.scrollHeight));
});
}
private openSaves(mode: "save" | "load", fromMain = false) {
this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => {
const grid = el("div", "pg-slots");
body.append(grid);
const render = (list: SaveMeta[]) => {
grid.textContent = "";
const bySlot = new Map(list.map((s) => [s.slot, s]));
const slots = [...(mode === "load" ? ["auto", "quick"] : []),
...Array.from({ length: SLOTS }, (_, i) => String(i + 1))];
for (const slot of slots) {
const meta = bySlot.get(slot);
const card = el("button", "pg-slot");
const label = slot === "auto" ? "Авто" : slot === "quick" ? "Быстрое" : `Слот ${slot}`;
card.append(el("b", "", label));
card.append(el("span", "pg-slot-title", meta ? meta.title : "Пусто"));
if (meta) card.append(el("small", "", new Date(meta.updated_at).toLocaleString()));
card.disabled = mode === "load" && !meta;
card.onclick = async () => {
if (mode === "save") {
if (meta && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
await this.save(slot);
render(await this.saves.list());
} else {
await this.load(slot);
}
};
grid.append(card);
}
};
this.saves.list().then(render).catch((e) => this.notify(String(e)));
}, fromMain ? () => this.showMainMenu() : undefined);
}
/** Save a change made in the settings screen: volumes go to this game or to everyone. */
private persistSettings(kind: "audio" | "reading") {
const { music, sound, voice, cps, autoDelay } = this.settings;
if (kind === "audio" && this.ownVolumes) {
this.volumes.write({ music, sound, voice });
return;
}
if (kind === "audio") Object.assign(this.sharedSettings, { music, sound, voice });
else Object.assign(this.sharedSettings, { cps, autoDelay });
saveJSON("pgvn:settings", this.sharedSettings);
}
private useOwnVolumes(on: boolean, fromMain: boolean) {
if (!this.volumes.available) return;
const { music, sound, voice } = this.settings;
if (on) {
this.ownVolumes = true;
this.volumes.write({ music, sound, voice }); // starts from what is playing now
} else {
this.ownVolumes = false;
this.volumes.clear();
Object.assign(this.settings, { music: this.sharedSettings.music, sound: this.sharedSettings.sound,
voice: this.sharedSettings.voice });
this.audio.setVolumes(this.settings);
}
this.openSettings(fromMain); // redraw the sliders with the values now in effect
}
private openSettings(fromMain = false) {
this.openScreen("Настройки", (body) => {
const s = this.settings;
const layout = el("div", "pg-settings");
body.append(layout);
const section = (title: string, description: string) => {
const card = el("section", "pg-settings-card");
card.append(el("h3", "", title), el("p", "pg-settings-hint", description));
layout.append(card);
return card;
};
const row = (parent: HTMLElement, label: string, min: number, max: number, step: number, value: number,
fmt: (v: number) => string, set: (v: number) => void, kind: "audio" | "reading") => {
const r = el("label", "pg-setting");
const heading = el("span", "pg-setting-head");
const out = el("output", "pg-setting-val", fmt(value));
const input = el("input");
Object.assign(input, { type: "range", min, max, step, value: String(value) });
input.setAttribute("aria-label", label);
const update = () => {
out.value = fmt(Number(input.value));
input.setAttribute("aria-valuetext", out.value);
input.style.setProperty("--pg-progress", `${(Number(input.value) - min) / (max - min) * 100}%`);
};
update();
input.oninput = () => {
set(Number(input.value));
update();
this.persistSettings(kind);
this.audio.setVolumes(this.settings);
};
heading.append(el("span", "", label), out);
r.append(heading, input);
parent.append(r);
};
const audio = section("Звук", this.ownVolumes
? "Громкость только для этой игры. 0% — выключить канал."
: "Общая громкость всех игр. 0% — выключить канал.");
const percent = (v: number) => `${Math.round(v * 100)}%`;
if (this.volumes.available) {
const toggle = el("label", "pg-check");
const box = el("input");
Object.assign(box, { type: "checkbox", checked: this.ownVolumes });
box.onchange = () => this.useOwnVolumes(box.checked, fromMain);
toggle.append(box, el("span", "", "Своя громкость для этой игры"));
audio.append(toggle);
}
row(audio, "Музыка", 0, 1, 0.01, s.music, percent, (v) => (s.music = v), "audio");
row(audio, "Звуки", 0, 1, 0.01, s.sound, percent, (v) => (s.sound = v), "audio");
row(audio, "Голос", 0, 1, 0.01, s.voice, percent, (v) => (s.voice = v), "audio");
const reading = section("Чтение", "Настройте темп появления текста и автопрочтения.");
row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v), "reading");
row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v), "reading");
const display = section("Игра", "Изменения сохраняются автоматически в этом браузере.");
const graphics = section("Графика", "Локальный генератор настраивается в меню профиля → Улучшение графики. Результат подхватывается при следующем показе картинки.");
const imageToggle = (label: string, checked: boolean, apply: (value: boolean) => void) => {
const row = el("label", "pg-check"); const box = el("input");
Object.assign(box, { type: "checkbox", checked });
box.onchange = () => apply(box.checked); row.append(box, el("span", "", label)); graphics.append(row);
};
imageToggle("Показывать улучшенные изображения", this.liveImages?.enabled ?? false, (value) => {
if (!this.liveImages) return;
this.liveImages.enabled = value;
this.liveImages.save();
// Refresh shown scene for immediate original/result comparison.
const shown = this.renderer.snapshot();
for (const image of shown) { const resolved = this.resolveImage(image.name); if (resolved) this.renderer.show(resolved, image.tag, image.at, { zorder: image.zorder }); }
});
imageToggle("Улучшать по ходу игры", this.liveImages?.automatic ?? false, (value) => { if (this.liveImages) { this.liveImages.automatic = value; this.liveImages.save(); } });
const langs = this.languages();
if (langs.length > 1) {
const r = el("label", "pg-setting");
r.append(el("span", "", "Язык"), this.languageSelect(langs, () => {}));
display.append(r);
}
const fs = el("button", "pg-wide", document.fullscreenElement ? "Выйти из полного экрана" : "Полный экран");
fs.onclick = () => {
const action = document.fullscreenElement ? document.exitFullscreen() : this.root.requestFullscreen();
void action.then(() => {
fs.textContent = document.fullscreenElement ? "Выйти из полного экрана" : "Полный экран";
}).catch(() => this.notify("Не удалось изменить полноэкранный режим"));
};
fs.hidden = !document.fullscreenEnabled;
display.append(fs);
}, fromMain ? () => this.showMainMenu() : undefined, true);
}
}
function progressLabel(p: number | undefined): string {
return p !== undefined && p < 0.995 ? ` — ${Math.round(p * 100)}%` : "";
}
function childBlock(node: Node, branch: number): Node[] {
if (node.op === "if") return node.branches[branch].body;
if (node.op === "menu") return node.items[branch].body;
return node.body;
}
export type { ImageDef };