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>
1463 lines
53 KiB
TypeScript
1463 lines
53 KiB
TypeScript
// 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 };
|