Files
pgames-manager/frontend/src/player/engine.ts
T
mrleo1nidandClaude Sonnet 5.5 9ee3e1ff6e UI: unified design system, light/dark themes, redesigned pages and player UI
Tokens and components in styles.css, shared Ui (dialogs, toasts), icons,
bottom tab bar on mobile, keyboard/gamepad navigation, library shelf with
filters and conversion badges, hero game page, restyled player screens.
Games list now returns per-game version counts. Adds docs/UI_DESIGN.md.

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

1463 lines
53 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, 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<SaveMeta[]>;
get(slot: string): Promise<SaveData | null>;
put(slot: string, title: string, data: SaveData): Promise<void>;
remove(slot: string): Promise<void>;
}
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<K extends keyof HTMLElementTagNameMap>(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<T>(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<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);
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 <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;
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])),
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<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 "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<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();
}
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<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) => {
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 };