// 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(); private labels = new Map(); private anchors = new Map(); private imagesByTag = new Map(); private originalImages: Record; private registeredImages: Record = {}; private store: Record = {}; private builtins: Record = {}; private persistent: Record; 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(); /** 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(); 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, /** 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 __ 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 { const data = decodeValue(loadJSON>(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 { 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 => { let next = source; const below = (n: number) => Math.floor(next() * n); const self: Record = { 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 = { 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 { 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 = { 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 = { 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(generator: Generator): Promise { 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 { 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((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((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): Promise { 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): Promise { this.interactionStart(); this.setSkip(false); return this.wait((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 { this.interactionStart(); this.setSkip(false); return this.wait((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 }, 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 }, frame?: Frame) { const values: Record = {}; 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 }) { // 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(setup: (done: (v: T) => void) => void): Promise { const gen = this.gen; return new Promise((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((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((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, 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 { 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 { 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 { 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((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(); 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 | 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 { 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 };