Files
pgames-manager/frontend/src/player/engine.ts
T
mrleo1nidandClaude Sonnet 5.5 ff82d882c0 feat: add ATL animation timelines and try/raise to the Ren'Py player
Python sandbox
- try/except/else/finally, raise (incl. re-raise), assert, for/while ... else
- Multi-generator and dict comprehensions
- Builtin exception hierarchy; user exceptions subclass it
- Inside try, missing keys/indexes, division by zero, attribute access on None
  and unknown names raise Python exceptions; lookups stay lenient elsewhere
- int()/float() raise ValueError on non-numeric strings

ATL
- converter/atl.py: set/move (linear, ease, easein, easeout), pause, repeat,
  parallel, choice, block, on show/hide and named transforms become a timeline
  shipped as game.json "atl"; Move() becomes a linear slide
- player/atl.ts: timeline runner wired into the renderer; `on hide` plays
  before the picture is removed
- function, time, contains, matrixcolor and expressions still block the import

Tests: exceptions/ATL fixtures and runtime tests, converter ATL tests; README
and ARCHITECTURE updated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 12:11:07 +03:00

1589 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// PGVN interpreter + in-game UI.
import { AudioSystem } from "./audio";
import { JumpSignal, PyDict, Python, decodeValue, 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 { 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 labels = new Map<string, LabelRef>();
private anchors = new Map<string, { label: string; path: number[]; params?: [string, Expr | null][] | null }>();
private imagesByTag = new Map<string, { attrs: string[]; key: string }[]>();
private store: Record<string, any> = {};
private builtins: Record<string, any> = {};
private persistent: Record<string, any>;
private py: Python;
private renderer: Renderer;
private audio: AudioSystem;
private settings: Settings;
private cur: Frame = { label: "", path: [0] };
private stack: Frame[] = [];
private gen = 0;
private waiters: (() => void)[] = [];
private running = false;
private skip = false;
private auto = false;
private windowShown = true;
private lastSay: { name: string; text: string; color?: string; char?: CharacterDef } | null = null;
private history: { name: string; text: string }[] = [];
private nvlLines: HTMLElement[] = [];
private interactions = 0;
// UI
private container: HTMLDivElement;
private textbox!: HTMLDivElement;
private namebox!: HTMLDivElement;
private textEl!: HTMLDivElement;
private ctc!: HTMLSpanElement;
private menuEl!: HTMLDivElement;
private nvlEl!: HTMLDivElement;
private centeredEl!: HTMLDivElement;
private quick!: HTMLDivElement;
private screen!: HTMLDivElement;
private notifyEl!: HTMLDivElement;
private baseSize: number;
private resizeObserver: ResizeObserver;
constructor(
private root: HTMLElement,
private game: GameData,
private manifest: Manifest,
modules: ScriptModule[],
private saves: SaveApi,
private persistentKey: string,
private onExit: () => void,
private tr: Translator,
private loadLocale: (lang: string) => Promise<Translator>,
) {
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) };
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) {
const value = this.settings[key];
const [min, max] = key === "cps" ? [0, 150] : key === "autoDelay" ? [0.3, 4] : [0, 1];
this.settings[key] = typeof value === "number" && Number.isFinite(value)
? Math.max(min, Math.min(max, value)) : DEFAULT_SETTINGS[key];
}
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.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,
atl: (name) => game.atl?.[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 ? `/api/versions/${this.manifest.versionId}/files/${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 <tag>_<group>_<attr>
const extra: Layer[] = [];
for (const l of layers) {
if (!l.autoGroup) continue;
for (const a of selected) {
const ref = `${tag}_${l.autoGroup}_${a}`;
if (this.game.images[ref] && !layers.some((x) => x.attr === a))
extra.push({ attr: a, group: l.autoGroup, img: { ref } });
}
}
return { def: { ...def, layered: [...layers, ...extra], selected }, name: [tag, ...selected] };
}
private imageFromSpec(spec: any): ResolvedImage | null {
if (spec.name) return this.resolveImage(spec.name);
if (spec.text !== undefined) return { def: { text: spec.text }, name: ["text"] };
if (spec.textExpr) return { def: { textExpr: spec.textExpr }, name: ["text"] };
if (spec.expr) {
const v = this.safeEval(spec.expr);
if (typeof v !== "string") return null;
if (v.startsWith("#")) return { def: { color: v }, name: [v] };
if (v.includes(".")) return { def: { file: v }, name: [v.split("/").pop()!.split(".")[0]] };
return this.resolveImage(v.split(" "));
}
return null;
}
// --- python environment ----------------------------------------------------------
private safeEval(e: Expr | null | undefined): any {
try {
return this.py.eval(e);
} catch (err) {
if (err instanceof JumpSignal) throw err;
if (this.game.engine === "naninovel") throw err;
console.warn("[pgvn] ошибка выражения", err);
return null;
}
}
private makePersistent(): Record<string, any> {
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
const save = () => saveJSON(this.persistentKey, encodeValue(data));
return new Proxy(data, {
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined : null),
set: (t, k: string, v) => {
t[k] = v;
save();
return true;
},
});
}
private makeBuiltins(): Record<string, any> {
const kwless = (args: any[]) =>
args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args;
const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {};
const 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<string, any> = {
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])),
_: (s: string) => s,
__: (s: string) => this.tr.str(s),
...userBuiltins(this.py),
};
}
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.applyLocaleFont();
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<string, any> }, 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<string, any> },
frame?: Frame) {
const values: Record<string, any> = {};
params.forEach(([p, def], i) => {
if (args && i < args.a.length) values[p] = args.a[i];
else if (args && p in args.kw) values[p] = args.kw[p];
else values[p] = def ? this.safeEval(def) : null;
});
if (frame) {
frame.saved = {};
for (const p of Object.keys(values)) frame.saved[p] = { had: p in this.store, value: this.store[p] };
}
Object.assign(this.store, values);
}
private call(target: string, argExprs?: { a: Expr[]; kw: Record<string, Expr> }) {
// arguments are evaluated in the caller's scope, before parameters are bound
const args = argExprs && {
a: argExprs.a.map((e) => this.safeEval(e)),
kw: Object.fromEntries(Object.entries(argExprs.kw).map(([k, e]) => [k, this.safeEval(e)])),
};
const frame: Frame = { label: this.cur.label, path: [...this.cur.path] };
this.stack.push(frame);
this.jump(target, args, frame);
}
private doReturn() {
const frame = this.stack.pop();
if (!frame) {
this.cur = { label: "", path: [0] };
return;
}
for (const [p, old] of Object.entries(frame.saved ?? {})) {
if (old.had) this.store[p] = old.value;
else delete this.store[p];
}
this.cur = { label: frame.label, path: frame.path };
this.advance();
}
private markSeen(label: string) {
const seen: string[] = this.persistent._seen ?? [];
if (!seen.includes(label)) this.persistent._seen = [...seen, label];
}
// --- main loop --------------------------------------------------------------------
private wait<T = void>(setup: (done: (v: T) => void) => void): Promise<T> {
const gen = this.gen;
return new Promise<T>((resolve, reject) => {
const wake = () => reject(new Restart());
this.waiters.push(wake);
setup((v) => {
this.waiters = this.waiters.filter((w) => w !== wake);
if (gen === this.gen) resolve(v);
else reject(new Restart());
});
});
}
private wakeAll() {
const ws = this.waiters;
this.waiters = [];
ws.forEach((w) => w());
}
private async loop() {
const gen = this.gen;
this.running = true;
try {
while (gen === this.gen) {
if (!this.cur.label) {
this.showMainMenu();
return;
}
const node = this.node();
if (!node) {
this.settle();
if (!this.node()) {
this.cur = { label: "", path: [0] };
}
continue;
}
try {
await this.exec(node);
} 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 "nani_exit":
this.stack = [];
this.cur = { label: "", path: [0] };
return;
case "nani_add_choice":
(this.store.__nani_choices ??= []).push({ text: node.what, tid: node.tid,
target: node.target, handler: node.handler });
this.advance();
return;
case "nani_clear_choice":
this.store.__nani_choices = (this.store.__nani_choices ?? []).filter(
(choice: any) => node.handler !== "*" && choice.handler !== node.handler);
this.advance();
return;
case "nani_menu": {
const choices: { text: string; tid: string; target: string }[] = this.store.__nani_choices ?? [];
if (!choices.length) throw new Error("Naninovel: Stop без доступных вариантов выбора");
this.interactionStart();
this.setSkip(false);
const target = await this.wait<string>((done) => {
this.menuEl.textContent = "";
for (const choice of choices) {
const button = el("button", "pg-choice");
renderText(button, parseText(this.interp(this.tr.line(choice.tid, choice.text))), Infinity, this.baseSize);
button.onclick = (event) => { event.stopPropagation(); done(choice.target); };
this.menuEl.append(button);
}
this.menuEl.classList.remove("pg-hidden");
});
this.menuEl.classList.add("pg-hidden");
this.store.__nani_choices = [];
this.jump(target);
return;
}
case "nani_hide_characters":
this.renderer.commit();
for (const image of this.renderer.snapshot()) {
if (image.tag.startsWith("nani-char-")) this.renderer.hide(image.tag);
}
if (node.duration) await this.renderer.transition({ t: "dissolve", d: node.duration });
this.advance();
return;
case "say":
await this.say(node);
this.advance();
return;
case "scene":
case "show":
case "hide": {
if (node.with) this.renderer.commit();
if (node.op === "scene") {
this.renderer.scene();
this.clearNvl();
}
if (node.op === "hide") this.renderer.hide(node.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;
if (this.game.engine === "naninovel") 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 (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];
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 = this.interp(this.tr.line(node.tid, node.what));
raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
let prefixLen = 0;
if (mode === "extend" && this.lastSay) {
prefixLen = [...plainText(parseText(this.lastSay.text))].length;
raw = this.lastSay.text + raw;
}
const parsed = parseText(raw);
this.lastSay = { name, text: raw, color: char.color, char };
if (mode !== "extend") this.history.push({ name, text: plainText(parsed) });
else if (this.history.length) this.history[this.history.length - 1].text = plainText(parsed);
if (this.history.length > 300) this.history.shift();
let target: HTMLElement;
if (mode === "centered") {
this.textbox.classList.add("pg-hidden");
this.centeredEl.classList.remove("pg-hidden");
target = this.centeredEl;
} else if (mode === "nvl") {
this.textbox.classList.add("pg-hidden");
this.nvlEl.classList.remove("pg-hidden");
const line = el("div", "pg-nvl-line");
if (name) {
const who = el("span", "pg-nvl-name", name);
if (char.color) who.style.color = char.color;
line.append(who);
}
const what = el("span", "pg-nvl-what");
line.append(what);
this.nvlEl.append(line);
this.nvlLines.push(line);
target = what;
} else {
this.centeredEl.classList.add("pg-hidden");
this.nvlEl.classList.add("pg-hidden");
this.textbox.classList.remove("pg-hidden");
this.namebox.textContent = name;
this.namebox.classList.toggle("pg-hidden", !name);
this.namebox.style.color = char.color ?? "";
this.textEl.style.color = char.whatColor ?? "";
this.textEl.style.fontStyle = char.whatItalic ? "italic" : "";
this.textEl.style.fontWeight = char.whatBold ? "bold" : "";
target = this.textEl;
}
await this.typewrite(target, parsed, Math.max(prefixLen, fastPoint(parsed)), inMenu);
if (inMenu || node.interact === false) return;
if (!parsed.noWait) await this.waitAdvance(parsed.length);
this.audio.stop("voice");
this.centeredEl.classList.add("pg-hidden");
}
private async typewrite(target: HTMLElement, parsed: ReturnType<typeof parseText>, from: number, instant = false) {
this.ctc.remove();
const total = parsed.length;
const waits = waitPoints(parsed).filter((w) => w.at > from);
let shown = from;
const cps = this.settings.cps;
if (instant || cps <= 0 || this.skip) {
renderText(target, parsed, total, this.baseSize);
target.append(this.ctc);
return;
}
let completed = false;
const finishClick = () => {
completed = true;
};
this.onAdvance = finishClick;
try {
for (const stop of [...waits.map((w) => w.at), total]) {
let last = performance.now();
while (shown < stop && !completed && !this.skip) {
await this.frame();
const now = performance.now();
shown = Math.min(stop, shown + ((now - last) / 1000) * cps);
last = now;
renderText(target, parsed, Math.floor(shown), this.baseSize);
}
shown = completed || this.skip ? total : stop;
renderText(target, parsed, Math.floor(shown), this.baseSize);
if (completed || this.skip) break;
const w = waits.find((x) => x.at === stop);
if (w) {
if (w.seconds !== null) await this.pause(w.seconds);
else await this.waitAdvance(0);
this.onAdvance = finishClick;
}
}
} finally {
this.onAdvance = null;
}
target.append(this.ctc);
}
private frame(): Promise<void> {
return this.wait((done) => requestAnimationFrame(() => done()));
}
private onAdvance: (() => void) | null = null;
/** Wait for the player to advance (click/space), or skip/auto. */
private waitAdvance(chars: number): Promise<void> {
return this.wait((done) => {
let timer = 0;
const finish = () => {
clearTimeout(timer);
this.onAdvance = null;
this.skipTick = null;
done();
};
this.onAdvance = finish;
if (this.skip) timer = window.setTimeout(finish, 40);
else if (this.auto) timer = window.setTimeout(finish, (1 + chars / 20) * this.settings.autoDelay * 1000);
this.skipTick = () => {
clearTimeout(timer);
if (this.skip) timer = window.setTimeout(finish, 40);
else if (this.auto) timer = window.setTimeout(finish, 1000 * this.settings.autoDelay);
};
});
}
private skipTick: (() => void) | null = null;
private pause(seconds: number | null): Promise<void> {
if (seconds === null) {
this.interactionStart();
return this.waitAdvance(0);
}
if (this.skip) return Promise.resolve();
return this.wait((done) => {
const timer = window.setTimeout(() => {
this.onAdvance = null;
done();
}, seconds * 1000);
this.onAdvance = () => {
clearTimeout(timer);
this.onAdvance = null;
done();
};
});
}
private async menu(node: Node) {
this.interactionStart();
const chosen: string[] = node.set ? this.safeEval(node.set) ?? [] : [];
if (node.say) await this.say(node.say, true);
else if (!this.windowShown) this.textbox.classList.add("pg-hidden");
const items = node.items
.map((item: any, i: number) => ({ ...item, i }))
.filter((item: any) => (item.if === null || truthy(this.safeEval(item.if))) && !chosen.includes(item.text));
if (!items.length) {
this.advance();
return;
}
this.setSkip(false);
const index = await this.wait<number>((done) => {
this.menuEl.textContent = "";
for (const item of items) {
const b = el("button", "pg-choice");
renderText(b, parseText(this.interp(this.tr.str(item.text))), Infinity, this.baseSize);
b.onclick = (ev) => {
ev.stopPropagation();
done(item.i);
};
this.menuEl.append(b);
}
this.menuEl.classList.remove("pg-hidden");
});
this.menuEl.classList.add("pg-hidden");
if (Array.isArray(chosen) && node.set) chosen.push(node.items[index].text);
this.history.push({ name: "", text: `→ ${plainText(parseText(this.interp(this.tr.str(node.items[index].text))))}` });
this.enter(index);
}
private clearNvl() {
this.nvlLines.forEach((l) => l.remove());
this.nvlLines = [];
this.nvlEl.classList.add("pg-hidden");
}
private notify(msg: string) {
this.notifyEl.textContent = msg;
this.notifyEl.classList.remove("pg-hidden");
window.setTimeout(() => this.notifyEl.classList.add("pg-hidden"), 3000);
}
// --- 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();
}
/** 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),
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;
}
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.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.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.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 });
}
/** A translation may bring its own font when the game's fonts lack its glyphs. */
private applyLocaleFont() {
this.renderer.stage.classList.remove("pg-locale-font");
if (this.tr.font) this.loadFont("pg-locale-font", this.tr.font);
}
private loadFont(family: string, file?: string) {
const url = file ? this.fileUrl(file) : null;
if (!url) return;
const face = new FontFace(family, `url("${url}")`);
face.load().then((f) => {
document.fonts.add(f);
this.renderer.stage.classList.add(family);
}).catch(() => {});
}
/** In-player confirmation (the native confirm() breaks fullscreen and gamepads). */
private confirmAsk(text: string, okLabel = "Ок"): Promise<boolean> {
return new Promise((resolve) => {
const back = el("div", "pg-dialog-back");
const box = el("div", "pg-dialog");
box.setAttribute("role", "dialog");
box.append(el("p", "", text));
const row = el("div", "pg-dialog-row");
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
e.stopImmediatePropagation();
done(false);
};
const done = (v: boolean) => {
window.removeEventListener("keydown", onKey, true);
back.remove();
resolve(v);
};
const no = el("button", "", "Отмена");
no.onclick = () => done(false);
const yes = el("button", "pg-primary", okLabel);
yes.onclick = () => done(true);
window.addEventListener("keydown", onKey, true);
row.append(no, yes);
box.append(row);
back.append(box);
this.container.append(back);
yes.focus();
});
}
private advanceInput() {
if (this.skip) {
this.setSkip(false);
return;
}
if (!this.textbox.classList.contains("pg-ui-hidden")) this.onAdvance?.();
this.textbox.classList.remove("pg-ui-hidden");
}
private onKey = (ev: KeyboardEvent) => {
const screenOpen = !this.screen.classList.contains("pg-hidden");
if (ev.key === "Escape") {
if (this.screen.classList.contains("pg-main")) return;
if (screenOpen) this.dismissScreen();
else if (this.running) this.openGameMenu();
return;
}
if ((ev.target as HTMLElement)?.tagName === "INPUT") return;
if (screenOpen) return;
if (ev.key === " " || ev.key === "Enter") {
ev.preventDefault();
this.advanceInput();
} else if (ev.key === "Control" && !this.skip) {
this.setSkip(true);
} else if (ev.key === "Tab") {
ev.preventDefault();
this.setSkip(!this.skip);
} else if (ev.key === "a" || ev.key === "ф") {
this.setAuto(!this.auto);
} else if (ev.key === "h" || ev.key === "р") {
this.textbox.classList.toggle("pg-ui-hidden");
}
};
private onKeyUp = (ev: KeyboardEvent) => {
if (ev.key === "Control") this.setSkip(false);
};
private screenOnClose: (() => void) | null = null;
private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void,
unscaled = false) {
this.placeScreen(unscaled);
this.screen.textContent = "";
this.screen.classList.remove("pg-main");
this.container.classList.remove("pg-in-main");
this.screen.style.backgroundImage = "";
this.screenOnClose = onClose ?? null;
const head = el("div", "pg-screen-head");
head.append(el("h2", "", title));
const close = el("button", "pg-close", "✕");
close.setAttribute("aria-label", "Закрыть");
close.onclick = () => this.dismissScreen();
head.append(close);
const body = el("div", "pg-screen-body");
this.screen.append(head, body);
build(body);
this.screen.classList.remove("pg-hidden");
}
private dismissScreen() {
const fn = this.screenOnClose;
if (fn) fn();
else this.closeScreen();
}
private closeScreen() {
this.screenOnClose = null;
this.screen.classList.add("pg-hidden");
this.screen.classList.remove("pg-main");
this.container.classList.remove("pg-in-main");
this.screen.style.backgroundImage = "";
this.placeScreen(false);
}
private placeScreen(unscaled: boolean) {
this.screen.classList.toggle("pg-settings-screen", unscaled);
(unscaled ? this.container : this.renderer.stage).append(this.screen);
}
private showMainMenu() {
this.placeScreen(false);
this.running = false;
this.gen++;
this.wakeAll();
this.audio.stopAll();
this.textbox.classList.add("pg-hidden");
this.menuEl.classList.add("pg-hidden");
this.clearNvl();
this.screen.textContent = "";
this.screen.classList.add("pg-main");
this.container.classList.add("pg-in-main");
const bg = this.game.ui.mainMenu ? this.fileUrl(this.game.ui.mainMenu) : null;
if (bg) this.screen.style.backgroundImage = `url("${bg}")`;
const panel = el("div", "pg-main-panel");
panel.append(el("h1", "", this.game.title ?? "Новелла"));
const btn = (label: string, fn: () => void) => {
const b = el("button", "", label);
b.onclick = fn;
panel.append(b);
return b;
};
btn("Начать", () => this.newGame());
const cont = btn("Продолжить", () => void this.load("auto"));
cont.style.display = "none";
this.saves.list().then((list) => {
if (list.some((s) => s.slot === "auto")) cont.style.display = "";
}).catch(() => {});
btn("Загрузить", () => this.openSaves("load", true));
btn("Настройки", () => this.openSettings(true));
const langs = this.languages();
if (langs.length > 1) {
const select = this.languageSelect(langs, () => this.showMainMenu());
select.classList.add("pg-lang");
panel.append(select);
}
btn("В библиотеку", () => this.onExit());
this.screen.append(panel);
this.screen.classList.remove("pg-hidden");
}
private languages(): string[] {
return [this.game.sourceLang ?? "und", ...Object.keys(this.game.locales ?? {})];
}
private languageSelect(langs: string[], after: () => void): HTMLSelectElement {
const select = el("select", "pg-select");
for (const lang of langs) {
const o = el("option", "", languageName(this.game, lang) + progressLabel(this.game.locales?.[lang]?.progress));
o.value = lang;
o.selected = lang === this.tr.lang;
select.append(o);
}
select.onchange = () => void this.setLanguage(select.value).then(after);
return select;
}
private openGameMenu() {
this.openScreen("Меню", (body) => {
const list = el("div", "pg-menu-list");
const add = (label: string, fn: () => void) => {
const b = el("button", "", label);
b.onclick = fn;
list.append(b);
};
add("Вернуться", () => this.closeScreen());
add("История", () => this.openHistory());
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
add("Сохранить", () => this.openSaves("save"));
add("Загрузить", () => this.openSaves("load"));
add("Настройки", () => this.openSettings());
add("Главное меню", () => {
void this.confirmAsk("Выйти в главное меню? Несохранённый прогресс будет потерян.", "Выйти")
.then((yes) => yes && this.showMainMenu());
});
add("В библиотеку", () => this.onExit());
body.append(list);
});
}
private openHistory() {
this.openScreen("История", (body) => {
const list = el("div", "pg-history");
for (const h of this.history) {
const row = el("div", "pg-history-row");
if (h.name) row.append(el("b", "", h.name));
row.append(el("span", "", h.text));
list.append(row);
}
body.append(list);
requestAnimationFrame(() => (list.scrollTop = list.scrollHeight));
});
}
private openSaves(mode: "save" | "load", fromMain = false) {
this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => {
const grid = el("div", "pg-slots");
body.append(grid);
const render = (list: SaveMeta[]) => {
grid.textContent = "";
const bySlot = new Map(list.map((s) => [s.slot, s]));
const slots = [...(mode === "load" ? ["auto", "quick"] : []),
...Array.from({ length: SLOTS }, (_, i) => String(i + 1))];
for (const slot of slots) {
const meta = bySlot.get(slot);
const card = el("button", "pg-slot");
const label = slot === "auto" ? "Авто" : slot === "quick" ? "Быстрое" : `Слот ${slot}`;
card.append(el("b", "", label));
card.append(el("span", "pg-slot-title", meta ? meta.title : "Пусто"));
if (meta) card.append(el("small", "", new Date(meta.updated_at).toLocaleString()));
card.disabled = mode === "load" && !meta;
card.onclick = async () => {
if (mode === "save") {
if (meta && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
await this.save(slot);
render(await this.saves.list());
} else {
await this.load(slot);
}
};
grid.append(card);
}
};
this.saves.list().then(render).catch((e) => this.notify(String(e)));
}, fromMain ? () => this.showMainMenu() : undefined);
}
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) => {
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();
saveJSON("pgvn:settings", this.settings);
this.audio.setVolumes(this.settings);
};
heading.append(el("span", "", label), out);
r.append(heading, input);
parent.append(r);
};
const audio = section("Звук", "Громкость меняется сразу. 0% — выключить канал.");
const percent = (v: number) => `${Math.round(v * 100)}%`;
row(audio, "Музыка", 0, 1, 0.01, s.music, percent, (v) => (s.music = v));
row(audio, "Звуки", 0, 1, 0.01, s.sound, percent, (v) => (s.sound = v));
row(audio, "Голос", 0, 1, 0.01, s.voice, percent, (v) => (s.voice = v));
const reading = section("Чтение", "Настройте темп появления текста и автопрочтения.");
row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v));
row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v));
const display = section("Игра", "Изменения сохраняются автоматически в этом браузере.");
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 };