// PGVN interpreter + in-game UI. import { AudioSystem, type Volumes } from "./audio"; import { JumpSignal, PyDict, Python, decodeValue, encodeValue, pyStr, truthy, 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 type { CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule, } from "./types"; export interface SaveMeta { slot: string; title: string; updated_at: string; } export interface SaveApi { list(): Promise; get(slot: string): Promise; put(slot: string, title: string, data: SaveData): Promise; remove(slot: string): Promise; } interface Settings extends Volumes { cps: number; // characters per second, 0 = instant autoDelay: number; // seconds per 20 characters in auto mode } const DEFAULT_SETTINGS: Settings = { cps: 45, autoDelay: 1.2, music: 0.7, sound: 0.8, voice: 1 }; const SLOTS = 12; interface LabelRef { label: Label; module: ScriptModule; index: number; } class Restart extends Error {} function el(tag: K, cls = "", text = ""): HTMLElementTagNameMap[K] { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; } function loadJSON(key: string, fallback: T): T { try { const raw = localStorage.getItem(key); return raw ? { ...fallback, ...JSON.parse(raw) } : fallback; } catch { return fallback; } } function saveJSON(key: string, value: unknown) { try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage unavailable */ } } export class Engine { private labels = new Map(); private anchors = new Map(); private imagesByTag = new Map(); 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 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, ) { 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.settings = loadJSON("pgvn:settings", DEFAULT_SETTINGS); this.persistent = this.makePersistent(); const scope: Scope = { get: (n) => (n in this.store ? this.store[n] : this.builtins[n]), has: (n) => n in this.store || n in this.builtins, set: (n, v) => void (this.store[n] = v), del: (n) => void delete this.store[n], }; this.py = new Python(scope); this.builtins = this.makeBuiltins(); 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), resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null, evalCond: (c) => truthy(this.safeEval(c)), transform: (name) => game.transforms[name] ?? null, text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))), }); this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {}); this.buildUI(); 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); } 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; return entry ? `/blobs/${entry.hash}` : null; } 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(" ") }; } /** 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; 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 random = { randint: (a: number, b: number) => a + Math.floor(Math.random() * (b - a + 1)), random: () => Math.random(), choice: (xs: any[]) => xs[Math.floor(Math.random() * xs.length)], shuffle: (xs: any[]) => { for (let i = xs.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [xs[i], xs[j]] = [xs[j], xs[i]]; } }, uniform: (a: number, b: number) => a + Math.random() * (b - a), }; 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: () => {}, get_playing: () => this.audio.current(ch)?.file ?? null, is_playing: () => this.audio.current(ch) !== null, }); const renpyImpl: Record = { random, 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); }, input: (...a: any[]) => { const [prompt, def] = kwless(a); const kw = kwOf(a); const v = window.prompt(pyStr(prompt ?? ""), pyStr(def ?? kw.default ?? "")); return (v ?? pyStr(def ?? kw.default ?? "")).slice(0, kw.length ?? 1000); }, 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", }; 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: {}, // 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, pow: Math.pow }, len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0), 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)], 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])), isinstance: () => false, _: (s: string) => s, __: (s: string) => this.tr.str(s), }; } private languagePrefs() { const engine = this; return { get language() { return engine.tr.renpyName; }, }; } private async setLanguage(lang: string) { try { this.tr = await this.loadLocale(lang); rememberLanguage(lang); this.notify(languageName(this.game, lang)); } catch (err) { this.notify(`Не удалось загрузить язык: ${(err as Error).message}`); } } private resetStore() { 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__" || 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) { this.notify(`Метка «${target}» не найдена`); console.warn("[pgvn] нет метки", target); if (frame) this.stack.pop(); this.advance(); return; } 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) { 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); } catch (err) { if (err instanceof JumpSignal) { if (err.kind === "jump") this.jump(err.target); 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; } console.error(err); this.notify(`Ошибка плеера: ${(err as Error).message}`); } finally { if (gen === this.gen) this.running = false; } } private async exec(node: Node) { switch (node.op) { 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.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 "camera": this.renderer.camera(node.at ?? []); this.advance(); return; case "return": this.doReturn(); return; case "exec": try { this.py.exec(node.code); } catch (err) { if (err instanceof JumpSignal) throw err; console.warn("[pgvn] ошибка python", err, node.code); } 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]); 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 } { const who: string | undefined = node.who; 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]; 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 { 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 = 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); } // --- session control -------------------------------------------------------------- private restart(setup: () => void) { this.gen++; this.wakeAll(); this.onAdvance = null; this.skipTick = null; 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(); } private newGame() { 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 = { label: this.game.entry, path: [0] }; this.markSeen(this.game.entry); this.settle(); }); } private snapshotState(): SaveData { return { v: 1, cur: { label: this.cur.label, path: [...this.cur.path] }, stack: this.stack.map((f) => ({ label: f.label, path: [...f.path], saved: encodeValue(f.saved) })), store: encodeValue(this.store), scene: this.renderer.snapshot(), 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 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; } this.restart(() => { this.resetStore(); Object.assign(this.store, decodeValue(data.store)); 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 = []; }); } 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.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.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")) return; this.advanceInput(); }); stage.addEventListener("wheel", (ev) => { if (ev.deltaY < 0 && this.screen.classList.contains("pg-hidden") && this.running) this.openHistory(); }, { passive: true }); } 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) => { if ((ev.target as HTMLElement)?.tagName === "INPUT") return; 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 (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) { 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.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 = ""; } private showMainMenu() { 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); } private openSettings(fromMain = false) { this.openScreen("Настройки", (body) => { const s = this.settings; const row = (label: string, min: number, max: number, step: number, value: number, fmt: (v: number) => string, set: (v: number) => void) => { const r = el("label", "pg-setting"); const out = el("span", "pg-setting-val", fmt(value)); const input = el("input"); Object.assign(input, { type: "range", min, max, step, value: String(value) }); input.oninput = () => { set(Number(input.value)); out.textContent = fmt(Number(input.value)); saveJSON("pgvn:settings", this.settings); this.audio.setVolumes(this.settings); }; r.append(el("span", "", label), input, out); body.append(r); }; row("Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "мгновенно" : `${v} симв/с`), (v) => (s.cps = v)); row("Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v)); row("Музыка", 0, 1, 0.05, s.music, (v) => `${Math.round(v * 100)}%`, (v) => (s.music = v)); row("Звуки", 0, 1, 0.05, s.sound, (v) => `${Math.round(v * 100)}%`, (v) => (s.sound = v)); row("Голос", 0, 1, 0.05, s.voice, (v) => `${Math.round(v * 100)}%`, (v) => (s.voice = v)); const langs = this.languages(); if (langs.length > 1) { const r = el("label", "pg-setting"); r.append(el("span", "", "Язык"), this.languageSelect(langs, () => {}), el("span", "")); body.append(r); } const fs = el("button", "pg-wide", "Полный экран"); fs.onclick = () => { if (document.fullscreenElement) void document.exitFullscreen(); else void this.root.requestFullscreen(); }; body.append(fs); }, fromMain ? () => this.showMainMenu() : undefined); } } 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 };