Games with their own save screens work: FileAction/FileSave/FileLoad/FileDelete/FilePage*, FileLoadable,
FileTime, FileSaveName, FileJson and friends use browser slots (a slot has no screenshot). Added
renpy.has_image/has_screen/list_files/get_mouse_pos/log/jump_out_of_context/random.seed,
renpy.translation.translate_string, type/id/vars/Flatten/LiveComposite/Composite, bare defaultdict/
deepcopy, md5/sha256 (checked against test vectors), uuid4, time.monotonic, re.compile().fullmatch,
math.isfinite and `{**a, "k": v}`. The converter whitelists them and accepts the hashlib/uuid imports.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2361 lines
97 KiB
TypeScript
2361 lines
97 KiB
TypeScript
// PGVN interpreter + in-game UI.
|
||
|
||
import { AudioSystem } from "./audio";
|
||
import { cutscene } from "./cutscene";
|
||
import { VolumeStore } from "./volume-store";
|
||
import { LiveImages } from "../imager/live";
|
||
import { BlockingCall, JumpSignal, PyDict, Python, decodeValue, liveObjects, nativeClasses, encodeValue, pyStr, truthy, userBuiltins, type Scope } from "./python";
|
||
import { Renderer, type ResolvedImage } from "./renderer";
|
||
import { fastPoint, interpolate, parseText, plainText, renderText, waitPoints } from "./text";
|
||
import { languageName, rememberLanguage, type Translator } from "./i18n";
|
||
import { naniText } from "./naninovel";
|
||
import { Displayable, ScreenManager, type ScreenHost } from "./screens";
|
||
import { musicRoomClass } from "./musicroom";
|
||
import { pythonModules } from "./pymodules";
|
||
import { parseExpression, parseStatements } from "./pyparse";
|
||
import { DEFAULT_SETTINGS, SLOTS, Restart, el, loadJSON, saveJSON, type SaveApi, type SaveMeta, type Settings } from "./engine-util";
|
||
import type {
|
||
CharacterDef, Expr, Frame, GameData, ImageDef, Label, Layer, Manifest, Node, SaveData, ScriptModule,
|
||
} from "./types";
|
||
|
||
export type { SaveApi, SaveMeta } from "./engine-util";
|
||
|
||
interface LabelRef {
|
||
label: Label;
|
||
module: ScriptModule;
|
||
index: number;
|
||
}
|
||
|
||
export class Engine {
|
||
private liveImages?: LiveImages;
|
||
private imageScales = new Map<string, number>();
|
||
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 originalImages: Record<string, ImageDef>;
|
||
private registeredImages: Record<string, ImageDef> = {};
|
||
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 screens: ScreenManager;
|
||
private screenFonts = new Map<string, string>();
|
||
/** Settings shared by all games; `settings` is what this game actually uses. */
|
||
private sharedSettings: Settings;
|
||
private ownVolumes = false;
|
||
private volumes: VolumeStore;
|
||
private replayStash: SaveData | null = null;
|
||
private seenImages = new Set<string>();
|
||
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>,
|
||
/** Storage key of this game's own volumes; without it the game always uses the shared ones. */
|
||
audioKey: string | null = null,
|
||
) {
|
||
this.volumes = new VolumeStore(audioKey);
|
||
this.originalImages = { ...game.images };
|
||
for (const mod of modules) {
|
||
let global = "";
|
||
mod.labels.forEach((label, index) => {
|
||
if (label.name.startsWith(".")) label.name = global + label.name;
|
||
else global = label.name.split(".")[0];
|
||
this.labels.set(label.name, { label, module: mod, index });
|
||
});
|
||
for (const [name, anchor] of Object.entries(mod.anchors ?? {})) this.anchors.set(name, anchor);
|
||
}
|
||
for (const key of Object.keys(game.images)) {
|
||
const [tag, ...attrs] = key.split(" ");
|
||
if (!this.imagesByTag.has(tag)) this.imagesByTag.set(tag, []);
|
||
this.imagesByTag.get(tag)!.push({ attrs, key });
|
||
}
|
||
|
||
this.sharedSettings = { ...loadJSON("pgvn:settings", DEFAULT_SETTINGS) };
|
||
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof Settings)[]) {
|
||
const value = this.sharedSettings[key];
|
||
const [min, max] = key === "cps" ? [0, 150] : key === "autoDelay" ? [0.3, 4] : [0, 1];
|
||
this.sharedSettings[key] = typeof value === "number" && Number.isFinite(value)
|
||
? Math.max(min, Math.min(max, value)) : DEFAULT_SETTINGS[key];
|
||
}
|
||
// a game that has no volumes of its own (a first launch) plays with the shared ones
|
||
this.settings = { ...this.sharedSettings };
|
||
const own = this.volumes.read();
|
||
if (own) {
|
||
Object.assign(this.settings, own);
|
||
this.ownVolumes = true;
|
||
}
|
||
this.persistent = this.makePersistent();
|
||
const scope: Scope = {
|
||
get: (n) => (Object.hasOwn(this.store, n) ? this.store[n] : this.builtins[n]),
|
||
has: (n) => Object.hasOwn(this.store, n) || Object.hasOwn(this.builtins, n),
|
||
set: (n, v) => void (this.store[n] = v),
|
||
del: (n) => void delete this.store[n],
|
||
};
|
||
this.py = new Python(scope);
|
||
this.screens = new ScreenManager(this.screenHost(), game.screens ?? {}, game.styles ?? {});
|
||
this.py.blockingNames = new Set([
|
||
"renpy.input", "renpy.say", "renpy.pause", "renpy.display_menu", "renpy.with_statement", "renpy.call_screen",
|
||
"narrator", ...Object.keys(game.characters), ...(game.blocking ?? []),
|
||
"ui.interact", "renpy.movie_cutscene",
|
||
]);
|
||
this.builtins = { ...this.makeBuiltins(), ...this.screens.builtins(), ...this.waitingBuiltins() };
|
||
|
||
const [w, h] = game.resolution;
|
||
this.baseSize = Number(game.ui.text_size) || Math.round(h * 0.0306);
|
||
this.container = el("div", "pg-root");
|
||
root.append(this.container);
|
||
this.renderer = new Renderer(this.container, w, h, {
|
||
url: (f) => this.fileUrl(f),
|
||
assetScale: (url) => this.imageScales.get(url) ?? 1,
|
||
resolveRef: (name) => this.resolveImage(name.split(" "))?.def ?? null,
|
||
evalCond: (c) => truthy(this.safeEval(c)),
|
||
transform: (name) => game.transforms[name] ?? null,
|
||
atl: (name) => game.atl?.[name] ?? null,
|
||
evalExpr: (expr, frame) => {
|
||
const saved = this.py.locals;
|
||
this.py.locals = frame ? [...saved, frame] : saved;
|
||
try {
|
||
return this.safeEval(expr);
|
||
} finally {
|
||
this.py.locals = saved;
|
||
}
|
||
},
|
||
text: (e) => this.interp(this.tr.str(pyStr(this.safeEval(e)))),
|
||
});
|
||
this.audio = new AudioSystem(this.settings, (f) => this.fileUrl(f), game.channels ?? {});
|
||
this.builtins.MusicRoom = musicRoomClass(this.audio);
|
||
this.buildUI();
|
||
if (manifest.versionId !== undefined) this.liveImages = new LiveImages(manifest.versionId, (message) => this.notify(`Улучшение графики: ${message}`));
|
||
this.resizeObserver = new ResizeObserver(() => this.renderer.fit(this.container));
|
||
this.resizeObserver.observe(this.container);
|
||
this.renderer.fit(this.container);
|
||
window.addEventListener("keydown", this.onKey);
|
||
window.addEventListener("keyup", this.onKeyUp);
|
||
window.addEventListener("pointermove", this.onPointer);
|
||
}
|
||
|
||
/** The pointer in the game's own coordinates (`renpy.get_mouse_pos`). */
|
||
private mousePos: [number, number] = [0, 0];
|
||
private onPointer = (event: PointerEvent) => {
|
||
const box = this.container.getBoundingClientRect();
|
||
const [w, h] = this.game.resolution ?? [1920, 1080];
|
||
if (box.width && box.height) this.mousePos = [Math.round((event.clientX - box.left) * w / box.width), Math.round((event.clientY - box.top) * h / box.height)];
|
||
};
|
||
|
||
destroy() {
|
||
window.removeEventListener("pointermove", this.onPointer);
|
||
this.liveImages?.destroy();
|
||
this.screens.destroy();
|
||
this.gen++;
|
||
this.wakeAll();
|
||
this.audio.stopAll();
|
||
this.resizeObserver.disconnect();
|
||
window.removeEventListener("keydown", this.onKey);
|
||
window.removeEventListener("keyup", this.onKeyUp);
|
||
this.container.remove();
|
||
}
|
||
|
||
// --- files & images ----------------------------------------------------------
|
||
|
||
fileUrl(path: string): string | null {
|
||
const clean = path.replace(/^<[^>]*>/, "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||
let logical: string | undefined = this.manifest.files[clean] ? clean : undefined;
|
||
if (!logical) {
|
||
const low = clean.toLowerCase();
|
||
for (const prefix of ["", "images/", "audio/", "music/", "sound/", "sounds/"]) {
|
||
logical = this.game.files[prefix + low];
|
||
if (logical) break;
|
||
}
|
||
}
|
||
const entry = logical ? this.manifest.files[logical] : undefined;
|
||
if (!entry || !logical) return null;
|
||
const source = `/api/versions/${this.manifest.versionId}/files/${entry.hash}`;
|
||
const improved = this.liveImages?.url(logical, entry.hash, source);
|
||
const url = improved || source;
|
||
const baseScale = this.game.assetScale?.[logical] ?? 1;
|
||
this.imageScales.set(url, baseScale * (improved ? this.liveImages!.scale(improved) : 1));
|
||
return url;
|
||
}
|
||
|
||
private resolveImage(name: string[]): ResolvedImage | null {
|
||
const full = name.join(" ");
|
||
const [tag, ...attrs] = name;
|
||
const layered = this.game.images[tag];
|
||
if (layered?.layered) return this.resolveLayered(tag, layered, attrs);
|
||
if (this.game.images[full]) return { def: this.game.images[full], name };
|
||
const candidates = this.imagesByTag.get(tag) ?? [];
|
||
let best: { attrs: string[]; key: string } | null = null;
|
||
for (const c of candidates) {
|
||
if (attrs.every((a) => c.attrs.includes(a)) && (!best || c.attrs.length < best.attrs.length)) best = c;
|
||
}
|
||
if (!best && attrs.length) {
|
||
// keep attributes of the currently shown image that the new ones don't replace
|
||
const current = this.renderer.attrsOf(tag)?.slice(1) ?? [];
|
||
for (const c of candidates) {
|
||
if (attrs.every((a) => c.attrs.includes(a)) || !c.attrs.some((a) => attrs.includes(a))) continue;
|
||
const keeps = current.filter((a) => c.attrs.includes(a)).length;
|
||
if (!best || keeps > 0) best = c;
|
||
}
|
||
}
|
||
if (!best) return null;
|
||
return { def: this.game.images[best.key], name: best.key.split(" ") };
|
||
}
|
||
|
||
private indexImage(name: string) {
|
||
const [tag, ...attrs] = name.split(" ");
|
||
const entries = this.imagesByTag.get(tag) ?? [];
|
||
if (!entries.some((entry) => entry.key === name)) entries.push({ attrs, key: name });
|
||
this.imagesByTag.set(tag, entries);
|
||
}
|
||
|
||
private registerImage(name: string, definition: ImageDef) {
|
||
Object.defineProperty(this.game.images, name, { value: definition, enumerable: true, configurable: true, writable: true });
|
||
Object.defineProperty(this.registeredImages, name, { value: definition, enumerable: true, configurable: true, writable: true });
|
||
this.indexImage(name);
|
||
}
|
||
|
||
/** Runtime renpy.image declarations become the same serializable image IR as static ones. */
|
||
private imageDefinition(value: any, depth = 0): ImageDef {
|
||
if (depth > 8) throw new Error("Слишком глубокое определение изображения");
|
||
if (typeof value === "string") return /^#[0-9a-f]{3,8}$/i.test(value) ? { color: value }
|
||
: Object.hasOwn(this.game.images, value) ? { ref: value } : { file: value };
|
||
if (!(value instanceof Displayable)) throw new Error("Неподдерживаемое значение renpy.image");
|
||
const [a, b, c] = value.args;
|
||
switch (value.kind) {
|
||
case "image": return this.imageDefinition(a, depth + 1);
|
||
case "scale": {
|
||
const child = this.imageDefinition(a, depth + 1);
|
||
return { ...child, props: { ...child.props, size: [b, c] } };
|
||
}
|
||
case "transform": {
|
||
const child = this.imageDefinition(a, depth + 1);
|
||
return { ...child, props: { ...child.props, ...value.kw } };
|
||
}
|
||
case "solid": return { color: pyStr(a) };
|
||
case "movie": return { movie: pyStr(value.kw.play ?? a), movieMask: value.kw.mask ? pyStr(value.kw.mask) : undefined,
|
||
movieLoop: value.kw.loop !== false };
|
||
case "text": return { text: pyStr(a) };
|
||
case "null": return { composite: [] };
|
||
}
|
||
throw new Error(`renpy.image: не поддерживается ${value.kind}`);
|
||
}
|
||
|
||
/** Attributes of a layeredimage: new ones replace current ones of the same group. */
|
||
private resolveLayered(tag: string, def: ImageDef, attrs: string[]): ResolvedImage {
|
||
const layers = def.layered!;
|
||
const groupOf = (a: string) => layers.find((l) => l.attr === a)?.group ?? null;
|
||
const removed = attrs.filter((a) => a.startsWith("-")).map((a) => a.slice(1));
|
||
const added = attrs.filter((a) => !a.startsWith("-"));
|
||
const current = (this.renderer.attrsOf(tag)?.slice(1) ?? []).filter((a) => {
|
||
if (removed.includes(a)) return false;
|
||
const g = groupOf(a);
|
||
return !added.some((n) => n === a || (g !== null && groupOf(n) === g));
|
||
});
|
||
const selected = [...current, ...added];
|
||
// `group x auto`: attributes backed by images named <tag>_<group>_<attr>
|
||
const extra: Layer[] = [];
|
||
for (const l of layers) {
|
||
if (!l.autoGroup) continue;
|
||
for (const a of selected) {
|
||
const ref = `${tag}_${l.autoGroup}_${a}`;
|
||
if (this.game.images[ref] && !layers.some((x) => x.attr === a))
|
||
extra.push({ attr: a, group: l.autoGroup, img: { ref } });
|
||
}
|
||
}
|
||
return { def: { ...def, layered: [...layers, ...extra], selected }, name: [tag, ...selected] };
|
||
}
|
||
|
||
private imageFromSpec(spec: any): ResolvedImage | null {
|
||
if (spec.name) return this.resolveImage(spec.name);
|
||
if (spec.text !== undefined) return { def: { text: spec.text }, name: ["text"] };
|
||
if (spec.textExpr) return { def: { textExpr: spec.textExpr }, name: ["text"] };
|
||
if (spec.expr) {
|
||
const v = this.safeEval(spec.expr);
|
||
if (typeof v !== "string") return null;
|
||
if (v.startsWith("#")) return { def: { color: v }, name: [v] };
|
||
if (v.includes(".")) return { def: { file: v }, name: [v.split("/").pop()!.split(".")[0]] };
|
||
return this.resolveImage(v.split(" "));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// --- python environment ----------------------------------------------------------
|
||
|
||
private safeEval(e: Expr | null | undefined): any {
|
||
try {
|
||
return this.py.eval(e);
|
||
} catch (err) {
|
||
if (err instanceof JumpSignal) throw err;
|
||
if (this.game.engine === "naninovel") throw err;
|
||
console.warn("[pgvn] ошибка выражения", err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private makePersistent(): Record<string, any> {
|
||
const data = decodeValue(loadJSON<Record<string, any>>(this.persistentKey, {}));
|
||
const save = () => saveJSON(this.persistentKey, encodeValue(data));
|
||
return new Proxy(data, {
|
||
get: (t, k: string) => (k in t ? t[k] : k === "toJSON" ? undefined : null),
|
||
set: (t, k: string, v) => {
|
||
t[k] = v;
|
||
save();
|
||
return true;
|
||
},
|
||
});
|
||
}
|
||
|
||
private makeBuiltins(): Record<string, any> {
|
||
const kwless = (args: any[]) =>
|
||
args.length && args[args.length - 1]?.__kw ? args.slice(0, -1) : args;
|
||
const kwOf = (args: any[]) => (args.length && args[args.length - 1]?.__kw) || {};
|
||
const seeded = (seed: any) => {
|
||
let state = [...String(seed)].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) | 0, 7) >>> 0;
|
||
return () => {
|
||
state = (state + 0x6d2b79f5) >>> 0;
|
||
let t = state;
|
||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
};
|
||
const makeRandom = (source: () => number): Record<string, any> => {
|
||
let next = source;
|
||
const below = (n: number) => Math.floor(next() * n);
|
||
const self: Record<string, any> = {
|
||
randint: (a: number, b: number) => a + below(b - a + 1),
|
||
randrange: (a: number, b?: number, step = 1) => {
|
||
const [lo, hi] = b === undefined ? [0, a] : [a, b];
|
||
const count = Math.max(0, Math.ceil((hi - lo) / step));
|
||
if (!count) throw new Error("randrange: пустой диапазон");
|
||
return lo + below(count) * step;
|
||
},
|
||
random: () => next(),
|
||
choice: (xs: any[]) => {
|
||
if (!xs.length) throw new Error("choice: пустая последовательность");
|
||
return xs[below(xs.length)];
|
||
},
|
||
choices: (xs: any[], ...a: any[]) => {
|
||
const kw = kwOf(a);
|
||
const weights: number[] | null = kw.weights ?? kwless(a)[0] ?? null;
|
||
const k = kw.k ?? 1;
|
||
const total = weights ? weights.reduce((s, w) => s + w, 0) : xs.length;
|
||
const out: any[] = [];
|
||
for (let n = 0; n < k; n++) {
|
||
if (!weights) { out.push(xs[below(xs.length)]); continue; }
|
||
let roll = next() * total, i = 0;
|
||
while (i < xs.length - 1 && roll >= weights[i]) roll -= weights[i++];
|
||
out.push(xs[i]);
|
||
}
|
||
return out;
|
||
},
|
||
sample: (xs: any[], k: number) => {
|
||
const pool = [...xs];
|
||
if (k > pool.length) throw new Error("sample: выборка больше совокупности");
|
||
for (let i = 0; i < k; i++) {
|
||
const j = i + below(pool.length - i);
|
||
[pool[i], pool[j]] = [pool[j], pool[i]];
|
||
}
|
||
return pool.slice(0, k);
|
||
},
|
||
shuffle: (xs: any[]) => {
|
||
for (let i = xs.length - 1; i > 0; i--) {
|
||
const j = below(i + 1);
|
||
[xs[i], xs[j]] = [xs[j], xs[i]];
|
||
}
|
||
},
|
||
uniform: (a: number, b: number) => a + next() * (b - a),
|
||
seed: (seed?: any) => { next = seed === undefined || seed === null ? Math.random : seeded(seed); return null; },
|
||
};
|
||
// `rng = renpy.random.Random(seed)`: an independent generator; a given seed makes it repeatable
|
||
self.Random = (seed?: any) => {
|
||
return makeRandom(seed === undefined || seed === null ? Math.random : seeded(seed));
|
||
};
|
||
return self;
|
||
};
|
||
const random = makeRandom(Math.random);
|
||
const music = (ch: string) => ({
|
||
play: (...a: any[]) => {
|
||
const kw = kwOf(a);
|
||
const files = kwless(a)[0];
|
||
this.audio.play(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String),
|
||
{ loop: kw.loop, fadein: kw.fadein, fadeout: kw.fadeout });
|
||
},
|
||
queue: (...a: any[]) => {
|
||
const kw = kwOf(a);
|
||
const files = kwless(a)[0];
|
||
this.audio.queue(kw.channel ?? ch, (Array.isArray(files) ? files : [files]).map(String), kw.loop);
|
||
},
|
||
stop: (...a: any[]) => this.audio.stop(kwOf(a).channel ?? ch, kwOf(a).fadeout ?? 0),
|
||
set_volume: () => {},
|
||
set_pause: (...a: any[]) => {
|
||
this.audio.setPaused(kwOf(a).channel ?? ch, truthy(kwless(a)[0] ?? true));
|
||
return null;
|
||
},
|
||
get_pause: (...a: any[]) => this.audio.paused(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||
get_pos: (...a: any[]) => this.audio.position(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||
get_duration: (...a: any[]) => this.audio.duration(kwOf(a).channel ?? kwless(a)[0] ?? ch),
|
||
set_pan: () => {},
|
||
get_playing: () => this.audio.current(ch)?.file ?? null,
|
||
is_playing: () => this.audio.current(ch) !== null,
|
||
});
|
||
const renpyImpl: Record<string, any> = {
|
||
random,
|
||
translation: { translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null) },
|
||
style: { rebuild: () => null },
|
||
// diagnostics only: nothing is written anywhere
|
||
log: () => null,
|
||
write_log: () => null,
|
||
get_filename_line: () => ["", 0],
|
||
has_image: (name: any) => this.resolveImage(pyStr(name).split(" ")) !== null,
|
||
has_screen: (name: any) => Boolean(this.game.screens?.[pyStr(name)]),
|
||
list_files: () => Object.keys(this.game.files ?? {}),
|
||
get_mouse_pos: () => [...this.mousePos],
|
||
jump_out_of_context: (label: string) => {
|
||
throw new JumpSignal("jump", label);
|
||
},
|
||
image_size: (name: any) => {
|
||
const text = pyStr(name);
|
||
const resolved = this.resolveImage(text.split(" "));
|
||
const url = this.fileUrl(resolved?.def?.file ?? text);
|
||
const probe = url ? new Image() : null;
|
||
if (probe) probe.src = url!;
|
||
// only a picture the browser already holds can be measured at once; a game catches the error
|
||
if (!probe || !probe.complete || !probe.naturalWidth) throw new Error(`IOError: размер ${text} недоступен`);
|
||
return [probe.naturalWidth, probe.naturalHeight];
|
||
},
|
||
music: music("music"),
|
||
sound: music("sound"),
|
||
play: (f: string, ...a: any[]) => this.audio.play(kwOf(a).channel ?? "sound", [f], {}),
|
||
notify: (msg: any) => this.notify(pyStr(msg)),
|
||
jump: (label: string) => {
|
||
throw new JumpSignal("jump", label);
|
||
},
|
||
call: (label: string) => {
|
||
throw new JumpSignal("call", label);
|
||
},
|
||
// calls that wait for the player return a request; the generator interpreter answers it
|
||
input: (...a: any[]) => new BlockingCall("input", [kwless(a)[0] ?? "", kwless(a)[1] ?? kwOf(a).default ?? ""], kwOf(a)),
|
||
say: (who: any, what: any, ...a: any[]) => new BlockingCall("say", [who, what], kwOf(a)),
|
||
pause: (...a: any[]) => new BlockingCall("pause", [kwless(a)[0] ?? kwOf(a).delay ?? null], kwOf(a)),
|
||
display_menu: (items: any, ...a: any[]) => new BlockingCall("menu", [items], kwOf(a)),
|
||
with_statement: (transition: any) => new BlockingCall("with", [transition]),
|
||
call_screen: (name: any, ...a: any[]) => new BlockingCall("screen", [name, ...kwless(a)], kwOf(a)),
|
||
movie_cutscene: (...a: any[]) => new BlockingCall("movie", kwless(a), kwOf(a)),
|
||
free_memory: () => null, // browser owns the decoded-media cache
|
||
image: (name: any, value: any) => { this.registerImage(Array.isArray(name) ? name.join(" ") : String(name), this.imageDefinition(value)); return null; },
|
||
show: (name: any, ...a: any[]) => {
|
||
const kw = kwOf(a);
|
||
const parts = String(name).split(" ");
|
||
const picture = this.resolveImage(parts);
|
||
if (!picture) return null;
|
||
const at = ((kw.at_list ?? []) as any[]).filter((t) => t && t.__transform).map((t) => ({ name: t.__transform }));
|
||
this.renderer.show(picture, kw.tag ?? parts[0], at, { zorder: kw.zorder ?? undefined, behind: kw.behind ?? undefined });
|
||
return null;
|
||
},
|
||
hide: (name: any) => {
|
||
this.renderer.hide(String(name).split(" ")[0]);
|
||
return null;
|
||
},
|
||
scene: () => {
|
||
this.renderer.scene();
|
||
this.clearNvl();
|
||
return null;
|
||
},
|
||
has_label: (l: string) => this.labels.has(l),
|
||
seen_label: (l: string) => truthy(this.persistent._seen?.includes?.(l)),
|
||
showing: (tag: string) => this.renderer.isShown(String(tag).split(" ")[0]),
|
||
get_attributes: (tag: string) => this.renderer.attrsOf(tag)?.slice(1) ?? [],
|
||
full_restart: () => {
|
||
throw new JumpSignal("jump", "__main_menu__");
|
||
},
|
||
quit: () => {
|
||
throw new JumpSignal("jump", "__quit__");
|
||
},
|
||
version: () => "pgvn",
|
||
get_showing_tags: () => this.renderer.snapshot().map((image) => image.tag),
|
||
run: (action: any) => {
|
||
this.screens.run(action, null);
|
||
return null;
|
||
},
|
||
pop_call: () => {
|
||
this.stack.pop();
|
||
return null;
|
||
},
|
||
curry: (f: any) => {
|
||
const arity = f?.__def ? f.__def.params.filter((p: any) => p.kind === "p" && !p.d).length : f?.length ?? 0;
|
||
const curried = (...args: any[]): any => (args.length >= arity ? f(...args) : (...more: any[]) => curried(...args, ...more));
|
||
return curried;
|
||
},
|
||
...this.screens.renpyApi(),
|
||
// presentation-only: nothing to roll back or replay, and the next transition is chosen by `with`
|
||
end_replay: () => {
|
||
this.endReplay();
|
||
return null;
|
||
},
|
||
partial: (f: any, ...bound: any[]) => (...args: any[]) => f(...bound, ...args),
|
||
loadable: (path: any) => this.fileUrl(String(path)) !== null,
|
||
is_init_phase: () => false,
|
||
call_stack_depth: () => this.stack.length,
|
||
const: (value: any) => value,
|
||
screenshot: () => null,
|
||
Keymap: () => null,
|
||
register_shader: () => null,
|
||
register_statement: () => null,
|
||
redraw: () => null,
|
||
set_tag_attributes: () => null,
|
||
stop_predict: () => null,
|
||
map_event: () => null,
|
||
count_displayables_in_layer: () => 0,
|
||
displayable: (value: any) => value,
|
||
variant: (name: string) => ["pc", "large", "web", "medium"].includes(String(name)),
|
||
change_language: (code: any) => void this.setLanguage(code === null || code === undefined ? this.game.sourceLang ?? "und" : String(code)),
|
||
seen_image: (name: any) => this.seenImages.has(String(name)),
|
||
block_rollback: () => null,
|
||
choice_for_skipping: () => null,
|
||
// text effects drawn by GPU shaders: the words stay, only the glow is missing
|
||
register_textshader: () => null,
|
||
save_persistent: () => null, // persistent data is written on every change
|
||
mark_label_seen: (label: any) => {
|
||
const seen: string[] = this.persistent._seen ?? [];
|
||
if (!seen.includes(String(label))) this.persistent._seen = [...seen, String(label)];
|
||
return null;
|
||
},
|
||
get_game_runtime: () => (performance.now() - this.startedAt) / 1000,
|
||
translate_string: (text: any, ...a: any[]) => this.tr.translateIn(pyStr(text), kwOf(a).language ?? kwless(a)[0] ?? null),
|
||
substitute: (text: any) => this.interp(pyStr(text)),
|
||
return_statement: () => {
|
||
throw new JumpSignal("return", "");
|
||
},
|
||
checkpoint: () => null,
|
||
start_predict: () => null,
|
||
start_predict_screen: () => null,
|
||
stop_predict_screen: () => null,
|
||
// named slots; the name is limited to what the server accepts
|
||
save: (name: any) => {
|
||
void this.save(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32), true);
|
||
return null;
|
||
},
|
||
load: (name: any) => {
|
||
void this.load(String(name).replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 32));
|
||
return null;
|
||
},
|
||
transition: () => null,
|
||
};
|
||
const renpy = new Proxy(renpyImpl, {
|
||
get: (t, k: string) => {
|
||
if (k in t) return t[k];
|
||
this.py.warnOnce(`renpy.${k} не поддерживается — пропущено`);
|
||
return () => null;
|
||
},
|
||
});
|
||
const num = (x: any) => (typeof x === "string" ? Number(x.trim()) : Number(x));
|
||
return {
|
||
renpy,
|
||
persistent: this.persistent,
|
||
config: { gestures: {}, character_id_prefixes: [], screen_width: this.game.resolution[0], screen_height: this.game.resolution[1] },
|
||
// games branch on the Ren'Py language name ("russian"); None means the source language
|
||
preferences: this.languagePrefs(),
|
||
_preferences: this.languagePrefs(),
|
||
gui: {},
|
||
store: new Proxy({}, { get: (_t, k: string) => this.store[k] ?? null,
|
||
set: (_t, k: string, v) => ((this.store[k] = v), true) }),
|
||
audio: this.game.audio,
|
||
random,
|
||
math: {
|
||
floor: Math.floor, ceil: Math.ceil, sqrt: Math.sqrt, pi: Math.PI, e: Math.E, pow: Math.pow,
|
||
sin: Math.sin, cos: Math.cos, tan: Math.tan, asin: Math.asin, acos: Math.acos, atan: Math.atan,
|
||
atan2: Math.atan2, exp: Math.exp, fabs: Math.abs, trunc: Math.trunc, hypot: Math.hypot,
|
||
log: (x: number, base?: number) => (base ? Math.log(x) / Math.log(base) : Math.log(x)),
|
||
log10: Math.log10, fmod: (a: number, b: number) => a % b,
|
||
modf: (x: number) => [x - Math.trunc(x), Math.trunc(x)],
|
||
degrees: (x: number) => (x * 180) / Math.PI, radians: (x: number) => (x * Math.PI) / 180,
|
||
isfinite: Number.isFinite, isnan: Number.isNaN, isinf: (x: number) => x === Infinity || x === -Infinity,
|
||
copysign: (a: number, b: number) => Math.abs(a) * (b < 0 || Object.is(b, -0) ? -1 : 1),
|
||
},
|
||
len: (x: any) => (x instanceof PyDict ? x.map.size : x?.length ?? 0),
|
||
// a read-only view: `"name" in globals()` works, writes do not reach the game
|
||
globals: () => new PyDict(Object.entries(this.store)),
|
||
print: () => null,
|
||
// code built at run time ("persistent." + name + " = 0") goes through a parser producing the same
|
||
// sandboxed IR; there is no other way to run source text
|
||
eval: (source: any) => this.py.eval(parseExpression(pyStr(source))),
|
||
exec: (source: any) => {
|
||
this.py.exec(parseStatements(pyStr(source)));
|
||
return null;
|
||
},
|
||
Borders: (...a: number[]) => a,
|
||
...pythonModules(),
|
||
...nativeClasses(),
|
||
build: { ...this.game.build },
|
||
ui: { adjustment: () => ({ value: 0, range: 1, page: 0 }),
|
||
interact: () => new BlockingCall("interact") },
|
||
divmod: (a: number, b: number) => [Math.floor(a / b), ((a % b) + b) % b],
|
||
pow: (a: number, b: number, m?: number) => (m ? Number((BigInt(a) ** BigInt(b)) % BigInt(m)) : a ** b),
|
||
_p: (text: any) => this.tr.str(pyStr(text)).replace(/[ \t]*\n[ \t]*/g, " ").trim(),
|
||
achievement: this.achievementApi(),
|
||
// objects the game can reach, as the garbage collector would list them
|
||
gc: { get_objects: () => liveObjects(this.store), collect: () => 0 },
|
||
_window_hide: () => { this.windowShown = false; this.textbox.classList.add("pg-hidden"); return null; },
|
||
_window_show: () => { this.windowShown = true; return null; },
|
||
...Object.fromEntries(["Dissolve", "Fade", "ImageDissolve", "MoveTransition", "CropMove", "PushMove",
|
||
"Pixellate", "ComposeTransition", "With"].map((n) => [n, () => null])),
|
||
str: (x: any) => pyStr(x),
|
||
int: (x: any) => Math.trunc(num(x)),
|
||
float: (x: any) => num(x),
|
||
bool: (x: any) => truthy(x),
|
||
abs: Math.abs,
|
||
round: (x: number, n = 0) => Math.round(x * 10 ** n) / 10 ** n,
|
||
min: (...a: any[]) => (a.length === 1 ? Math.min(...a[0]) : Math.min(...a)),
|
||
max: (...a: any[]) => (a.length === 1 ? Math.max(...a[0]) : Math.max(...a)),
|
||
sum: (xs: number[]) => xs.reduce((s, x) => s + x, 0),
|
||
any: (xs: any[]) => xs.some(truthy),
|
||
all: (xs: any[]) => xs.every(truthy),
|
||
range: (a: number, b?: number, step = 1) => {
|
||
const [lo, hi] = b === undefined ? [0, a] : [a, b];
|
||
const out: number[] = [];
|
||
for (let i = lo; step > 0 ? i < hi : i > hi; i += step) out.push(i);
|
||
return out;
|
||
},
|
||
list: (xs: any = []) => [...(xs instanceof PyDict ? xs.map.keys() : xs)],
|
||
tuple: (xs: any = []) => [...xs],
|
||
set: (xs: any = []) => [...new Set(xs)],
|
||
frozenset: (xs: any = []) => [...new Set(xs)],
|
||
dir: (o?: any) => (o === undefined ? Object.keys(this.store) : Object.keys(o ?? {})),
|
||
dict: (...a: any[]) => {
|
||
const d = new PyDict(Array.isArray(kwless(a)[0]) ? kwless(a)[0] : []);
|
||
for (const [k, v] of Object.entries(kwOf(a))) d.set(k, v);
|
||
return d;
|
||
},
|
||
sorted: (xs: any[]) => [...xs].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
||
reversed: (xs: any[]) => [...xs].reverse(),
|
||
enumerate: (xs: any[]) => [...xs].map((x, i) => [i, x]),
|
||
zip: (...lists: any[][]) => lists[0].map((_, i) => lists.map((l) => l[i])),
|
||
_: (s: string) => s,
|
||
__: (s: string) => this.tr.str(s),
|
||
...userBuiltins(this.py),
|
||
};
|
||
}
|
||
|
||
private languagePrefs() {
|
||
const engine = this;
|
||
return {
|
||
get language() {
|
||
return engine.tr.renpyName;
|
||
},
|
||
get_mute: () => false,
|
||
get_volume: () => 1,
|
||
};
|
||
}
|
||
|
||
private async setLanguage(lang: string) {
|
||
try {
|
||
this.tr = await this.loadLocale(lang);
|
||
rememberLanguage(lang);
|
||
this.applyLocaleFont();
|
||
this.notify(languageName(this.game, lang));
|
||
} catch (err) {
|
||
this.notify(`Не удалось загрузить язык: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
/** Python names that make the player wait: the game's characters and narrator, Character(...) objects. */
|
||
private waitingBuiltins(): Record<string, any> {
|
||
const kwOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw) || {};
|
||
const posOf = (a: any[]) => (a.length && a[a.length - 1]?.__kw ? a.slice(0, -1) : a);
|
||
const speak = (who: any) => (...a: any[]) => new BlockingCall("say", [who, posOf(a)[0]], kwOf(a));
|
||
const out: Record<string, any> = { narrator: speak(null) };
|
||
for (const id of Object.keys(this.game.characters)) out[id] = speak(id);
|
||
out.Character = (...a: any[]) => {
|
||
const kw = kwOf(a);
|
||
const speaker: any = (...b: any[]) => new BlockingCall("say", [speaker, posOf(b)[0]], kwOf(b));
|
||
speaker.__character = true;
|
||
speaker.name = typeof posOf(a)[0] === "string" ? posOf(a)[0] : null;
|
||
speaker.color = kw.color ?? null;
|
||
return speaker;
|
||
};
|
||
// transitions are values in Python (`renpy.with_statement(dissolve)`), transforms are named by at_list
|
||
const transitions: Record<string, any> = {
|
||
dissolve: { t: "dissolve", d: 0.5 }, fade: { t: "fade", out: 0.5, hold: 0, in: 0.5, color: "#000000" },
|
||
move: { t: "move", d: 0.5 }, ease: { t: "move", d: 0.5 }, vpunch: { t: "punch", dir: "v", d: 0.3 },
|
||
hpunch: { t: "punch", dir: "h", d: 0.3 }, ...this.game.transitions,
|
||
};
|
||
Object.assign(out, transitions);
|
||
out.Dissolve = (d = 0.5) => ({ t: "dissolve", d });
|
||
out.Fade = (outTime = 0.5, hold = 0, inTime = 0.5, ...a: any[]) =>
|
||
({ t: "fade", out: outTime, hold, in: inTime, color: kwOf(a).color ?? "#000000" });
|
||
for (const name of [...Object.keys(this.game.transforms), ...Object.keys(this.game.atl ?? {}),
|
||
"left", "right", "center", "truecenter", "topleft", "topright", "top", "default", "reset", "offscreenleft",
|
||
"offscreenright"]) {
|
||
if (!(name in out)) out[name] = { __transform: name };
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Run a generator of the Python interpreter: each waiting call is answered here. */
|
||
private async drive<T>(generator: Generator<BlockingCall, T, any>): Promise<T> {
|
||
let step = generator.next();
|
||
while (!step.done) {
|
||
let answer: any;
|
||
try {
|
||
answer = await this.answerBlocking(step.value);
|
||
} catch (err) {
|
||
if (err instanceof Restart || err instanceof JumpSignal) {
|
||
generator.return(undefined as T);
|
||
throw err;
|
||
}
|
||
step = generator.throw(err); // Python code may catch it
|
||
continue;
|
||
}
|
||
step = generator.next(answer);
|
||
}
|
||
return step.value;
|
||
}
|
||
|
||
private async answerBlocking(request: BlockingCall): Promise<any> {
|
||
const [first, second] = request.args;
|
||
switch (request.kind) {
|
||
case "say": {
|
||
const node: Node = { op: "say", what: "", whatExpr: { k: "const", v: pyStr(second) },
|
||
interact: request.kw.interact !== false };
|
||
if (typeof first === "string") node.who = first;
|
||
else if (first && first.__character) node.whoObj = first;
|
||
await this.say(node);
|
||
return null;
|
||
}
|
||
case "pause":
|
||
await this.pause(first === null || first === undefined ? null : Number(first));
|
||
return null;
|
||
case "input":
|
||
return this.askText(pyStr(first), pyStr(second), request.kw);
|
||
case "menu":
|
||
return this.chooseItem(first);
|
||
case "with":
|
||
await this.renderer.transition(first && typeof first === "object" ? first : null);
|
||
return null;
|
||
case "screen": {
|
||
const name = String(first);
|
||
const value = await this.wait<any>((done) => this.screens.show(name, request.args.slice(1), request.kw, { call: true, onReturn: done }));
|
||
this.screens.hideCall(name);
|
||
return value ?? null;
|
||
}
|
||
case "interact": {
|
||
this.interactionStart();
|
||
this.screens.refresh();
|
||
let cancel = () => {};
|
||
try { return await this.wait<any>((done) => { cancel = this.screens.interact(done); }); }
|
||
finally { cancel(); }
|
||
}
|
||
case "movie":
|
||
return this.movieCutscene(request.args, request.kw);
|
||
}
|
||
throw new Error(`Неизвестный запрос ${request.kind}`);
|
||
}
|
||
|
||
private async movieCutscene(args: any[], kw: Record<string, any>): Promise<boolean> {
|
||
const filename = this.interp(pyStr(args[0] ?? kw.filename));
|
||
const src = this.fileUrl(filename);
|
||
if (!src) throw new Error(`Не найден ролик: ${filename}`);
|
||
const delay = args[1] ?? kw.delay ?? null;
|
||
const loops = Number(args[2] ?? kw.loops ?? 0);
|
||
if ((delay !== null && (!Number.isFinite(delay) || delay < -1)) || !Number.isInteger(loops) || loops < -1)
|
||
throw new Error("Некорректная длительность или число повторений ролика");
|
||
const stopMusic = args[3] ?? kw.stop_music ?? true;
|
||
const paused = this.audio.paused("music");
|
||
if (stopMusic) this.audio.setPaused("music", true);
|
||
const restoreScreens = this.screens.suspend();
|
||
const movie = cutscene(this.container, src, { delay, loops, volume: this.audio.mixerVolume("sfx") });
|
||
try {
|
||
const result = await this.wait<{ value?: boolean; error?: any }>((done) => {
|
||
movie.finished.then((value) => done({ value }), (error) => done({ error }));
|
||
});
|
||
if (result.error) throw result.error;
|
||
return result.value!;
|
||
} finally {
|
||
movie.destroy();
|
||
restoreScreens();
|
||
if (stopMusic) this.audio.setPaused("music", paused);
|
||
}
|
||
}
|
||
|
||
/** renpy.input(): a text field over the scene. */
|
||
private askText(prompt: string, initial: string, kw: Record<string, any>): Promise<string> {
|
||
this.interactionStart();
|
||
this.setSkip(false);
|
||
return this.wait<string>((done) => {
|
||
this.menuEl.textContent = "";
|
||
const box = el("div", "pg-input");
|
||
if (prompt) {
|
||
const label = el("div", "pg-input-prompt");
|
||
renderText(label, parseText(this.interp(prompt)), Infinity, this.baseSize);
|
||
box.append(label);
|
||
}
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.value = initial;
|
||
if (typeof kw.length === "number") input.maxLength = kw.length;
|
||
const ok = el("button", "pg-choice", "OK");
|
||
const submit = () => {
|
||
let value = input.value;
|
||
if (typeof kw.exclude === "string") value = [...value].filter((c) => !kw.exclude.includes(c)).join("");
|
||
if (typeof kw.allow === "string") value = [...value].filter((c) => kw.allow.includes(c)).join("");
|
||
done(value);
|
||
};
|
||
ok.onclick = (ev) => { ev.stopPropagation(); submit(); };
|
||
input.onkeydown = (ev) => {
|
||
ev.stopPropagation();
|
||
if (ev.key === "Enter") { ev.preventDefault(); submit(); }
|
||
};
|
||
input.onclick = (ev) => ev.stopPropagation();
|
||
box.append(input, ok);
|
||
this.menuEl.append(box);
|
||
this.menuEl.classList.remove("pg-hidden");
|
||
input.focus();
|
||
}).finally(() => this.menuEl.classList.add("pg-hidden"));
|
||
}
|
||
|
||
/** renpy.display_menu(): a list of (caption, value) pairs; a None value is a caption line. */
|
||
private chooseItem(items: any): Promise<any> {
|
||
this.interactionStart();
|
||
this.setSkip(false);
|
||
return this.wait<any>((done) => {
|
||
this.menuEl.textContent = "";
|
||
for (const item of (Array.isArray(items) ? items : []) as any[]) {
|
||
const [caption, value] = Array.isArray(item) ? item : [item, item];
|
||
if (value === null || value === undefined) {
|
||
this.menuEl.append(el("div", "pg-caption", pyStr(caption)));
|
||
continue;
|
||
}
|
||
const button = el("button", "pg-choice");
|
||
renderText(button, parseText(this.interp(pyStr(caption))), Infinity, this.baseSize);
|
||
button.onclick = (ev) => { ev.stopPropagation(); done(value); };
|
||
this.menuEl.append(button);
|
||
}
|
||
this.menuEl.classList.remove("pg-hidden");
|
||
}).finally(() => this.menuEl.classList.add("pg-hidden"));
|
||
}
|
||
|
||
private startedAt = performance.now();
|
||
|
||
/** Ren'Py's achievement API on top of persistent data (there is no Steam in the browser). */
|
||
private achievementApi() {
|
||
const got = (): string[] => this.persistent._achievements ?? [];
|
||
return {
|
||
steam: null,
|
||
register: () => null,
|
||
sync: () => null,
|
||
has: (name: any) => got().includes(String(name)),
|
||
grant: (name: any) => {
|
||
if (!got().includes(String(name))) {
|
||
this.persistent._achievements = [...got(), String(name)];
|
||
this.notify(`Достижение: ${pyStr(name)}`);
|
||
}
|
||
return null;
|
||
},
|
||
progress: () => null,
|
||
clear: (name: any) => {
|
||
this.persistent._achievements = got().filter((n) => n !== String(name));
|
||
return null;
|
||
},
|
||
clear_all: () => { this.persistent._achievements = []; return null; },
|
||
};
|
||
}
|
||
|
||
private screenHost(): ScreenHost {
|
||
const engine = this;
|
||
return {
|
||
error: (message) => this.fail(new Error(message)),
|
||
get audio() { return engine.audio; },
|
||
setMixer: (name, value) => {
|
||
this.audio.setMixer(name, value);
|
||
const key = name === "sfx" ? "sound" : name;
|
||
if (key in this.settings) {
|
||
(this.settings as any)[key] = value;
|
||
this.audio.setVolumes(this.settings);
|
||
this.persistSettings("audio");
|
||
}
|
||
},
|
||
py: this.py,
|
||
fileUrl: (path) => this.fileUrl(path),
|
||
image: (value) => this.screenImage(value),
|
||
text: (el, value, size) => renderText(el, parseText(this.interp(this.tr.str(value))), Infinity, size),
|
||
fontFamily: (file) => this.screenFont(file),
|
||
get baseSize() { return engine.baseSize; },
|
||
play: (channel, file, loop) => this.audio.play(channel, [file], { loop }),
|
||
stop: (channel) => this.audio.stop(channel, 0),
|
||
notify: (message) => this.notify(message),
|
||
jump: (label) => this.restart(() => this.guardedJump(label)),
|
||
call: (label) => this.restart(() => this.call(label)),
|
||
quit: () => this.onExit(),
|
||
mainMenu: () => this.restart(() => { this.stack = []; this.cur = { label: "", path: [0] }; }),
|
||
openMenu: () => this.openGameMenu(),
|
||
openUrl: (url) => {
|
||
if (/^https?:\/\//i.test(url)) window.open(url, "_blank", "noopener,noreferrer");
|
||
},
|
||
setLanguage: (code) => void this.setLanguage(code ?? this.game.sourceLang ?? "und"),
|
||
startReplay: (label, scope) => this.startReplay(label, scope),
|
||
endReplay: () => this.endReplay(),
|
||
quickSave: () => void this.save("quick"),
|
||
quickLoad: () => void this.load("quick"),
|
||
slotInfo: (slot) => {
|
||
if (!this.slotsRead) { this.slotsRead = true; void this.refreshSlots(); }
|
||
const meta = this.slotCache.get(slot);
|
||
return meta ? { title: meta.title, time: meta.updated_at } : null;
|
||
},
|
||
saveSlot: (slot, confirm) => void this.saveFileSlot(slot, confirm),
|
||
loadSlot: (slot) => void this.load(slot),
|
||
deleteSlot: (slot, confirm) => void this.deleteFileSlot(slot, confirm),
|
||
get filePage() { return String(engine.persistent._file_page ?? "1"); },
|
||
set filePage(page: string) { engine.persistent._file_page = page; engine.screens.refresh(); },
|
||
transform: (name) => this.game.transforms[name] ?? null,
|
||
atl: (name) => this.game.atl?.[name] ?? null,
|
||
};
|
||
}
|
||
|
||
/** A jump requested from a widget; the special menu/quit targets end the session instead. */
|
||
private guardedJump(label: string) {
|
||
try {
|
||
this.jump(label);
|
||
} catch (err) {
|
||
if (!(err instanceof Restart)) throw err;
|
||
this.cur = { label: "", path: [0] };
|
||
if (err.message === "__quit__") this.onExit();
|
||
}
|
||
}
|
||
|
||
private screenImage(value: string): HTMLElement | null {
|
||
if (/^#[0-9a-f]{3,8}$/i.test(value)) {
|
||
const box = el("div");
|
||
box.style.background = value;
|
||
box.style.width = box.style.height = "100%";
|
||
return box;
|
||
}
|
||
const resolved = this.resolveImage(value.split(" "));
|
||
if (resolved) return this.renderer.displayable(resolved.def);
|
||
const url = this.fileUrl(value);
|
||
if (!url) return null;
|
||
return this.renderer.displayable({ file: value });
|
||
}
|
||
|
||
private screenFont(file: string): string | null {
|
||
let family = this.screenFonts.get(file);
|
||
if (!family) {
|
||
family = `pg-scr-${this.screenFonts.size}`;
|
||
this.screenFonts.set(file, family);
|
||
this.loadFont(family, file);
|
||
}
|
||
return family;
|
||
}
|
||
|
||
private resetStore() {
|
||
this.screens.clear();
|
||
this.game.images = { ...this.originalImages };
|
||
this.registeredImages = {};
|
||
this.imagesByTag.clear();
|
||
for (const name of Object.keys(this.game.images)) this.indexImage(name);
|
||
this.store = {};
|
||
try {
|
||
this.py.exec(this.game.init);
|
||
} catch (err) {
|
||
console.warn("[pgvn] ошибка init", err);
|
||
}
|
||
}
|
||
|
||
private newStore() {
|
||
this.resetStore();
|
||
for (const [name, e] of this.game.defaults) {
|
||
if (!(name in this.store)) this.store[name] = this.safeEval(e);
|
||
}
|
||
}
|
||
|
||
// --- control flow ------------------------------------------------------------------
|
||
|
||
private blockFor(frame: Frame): Node[] {
|
||
let block = this.labels.get(frame.label)!.label.body;
|
||
for (let k = 0; k < frame.path.length - 1; k += 2) {
|
||
block = childBlock(block[frame.path[k]], frame.path[k + 1]);
|
||
}
|
||
return block;
|
||
}
|
||
|
||
private node(): Node | null {
|
||
if (!this.labels.has(this.cur.label)) return null;
|
||
return this.blockFor(this.cur)[this.cur.path[this.cur.path.length - 1]] ?? null;
|
||
}
|
||
|
||
/** After a path change, move past ended blocks (falling through labels / looping whiles). */
|
||
private settle() {
|
||
for (let guard = 0; guard < 10000; guard++) {
|
||
const p = this.cur.path;
|
||
if (!this.labels.has(this.cur.label)) return;
|
||
const block = this.blockFor(this.cur);
|
||
if (p[p.length - 1] < block.length) return;
|
||
if (p.length === 1) {
|
||
const ref = this.labels.get(this.cur.label)!;
|
||
const next = ref.module.labels[ref.index + 1];
|
||
if (next) {
|
||
this.cur = { label: next.name, path: [0] };
|
||
continue;
|
||
}
|
||
this.doReturn();
|
||
return;
|
||
}
|
||
const parent = p.slice(0, -2);
|
||
const parentNode = this.blockFor({ label: this.cur.label, path: parent })[parent[parent.length - 1]];
|
||
this.cur.path = parent;
|
||
if (parentNode.op !== "while") this.cur.path[parent.length - 1]++;
|
||
}
|
||
}
|
||
|
||
private advance() {
|
||
this.cur.path[this.cur.path.length - 1]++;
|
||
this.settle();
|
||
}
|
||
|
||
private enter(branch: number) {
|
||
this.cur.path.push(branch, 0);
|
||
this.settle();
|
||
}
|
||
|
||
private jump(target: string, args?: { a: any[]; kw: Record<string, any> }, frame?: Frame) {
|
||
if (target === "main_menu_screen" || target === "_main_menu_screen") target = "__main_menu__";
|
||
if (target === "__main_menu__" || target === "__quit__") {
|
||
throw new Restart(target);
|
||
}
|
||
let name = target;
|
||
if (name.startsWith(".")) name = this.cur.label.split(".")[0] + name;
|
||
const anchor = this.labels.has(name) ? null : this.anchors.get(name);
|
||
if (!this.labels.has(name) && !anchor) {
|
||
throw new Error(`Метка «${target}» не найдена`);
|
||
}
|
||
const params = anchor ? anchor.params : this.labels.get(name)!.label.params;
|
||
if (params?.length) this.bindParams(params, args, frame);
|
||
this.cur = anchor ? { label: anchor.label, path: [...anchor.path] } : { label: name, path: [0] };
|
||
this.markSeen(name);
|
||
this.settle();
|
||
}
|
||
|
||
/** Label parameters are dynamically scoped: a call restores old values on return. */
|
||
private bindParams(params: [string, Expr | null][], args?: { a: any[]; kw: Record<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) {
|
||
if (this.replayStash) {
|
||
this.endReplay(); // the replayed label ran out: back to the game
|
||
return;
|
||
}
|
||
this.cur = { label: "", path: [0] };
|
||
return;
|
||
}
|
||
for (const [p, old] of Object.entries(frame.saved ?? {})) {
|
||
if (old.had) this.store[p] = old.value;
|
||
else delete this.store[p];
|
||
}
|
||
this.cur = { label: frame.label, path: frame.path };
|
||
this.advance();
|
||
}
|
||
|
||
private markSeen(label: string) {
|
||
const seen: string[] = this.persistent._seen ?? [];
|
||
if (!seen.includes(label)) this.persistent._seen = [...seen, label];
|
||
}
|
||
|
||
// --- main loop --------------------------------------------------------------------
|
||
|
||
private wait<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);
|
||
this.screens.refresh();
|
||
} catch (err) {
|
||
if (err instanceof JumpSignal) {
|
||
if (err.kind === "jump") this.jump(err.target);
|
||
else if (err.kind === "return") this.doReturn();
|
||
else this.call(err.target);
|
||
} else throw err;
|
||
}
|
||
}
|
||
} catch (err) {
|
||
if (err instanceof Restart) {
|
||
if (err.message === "__main_menu__") this.showMainMenu();
|
||
if (err.message === "__quit__") this.onExit();
|
||
return;
|
||
}
|
||
this.fail(err);
|
||
} finally {
|
||
if (gen === this.gen) this.running = false;
|
||
}
|
||
}
|
||
|
||
private screenArgs(node: Node) {
|
||
return {
|
||
args: (node.args ?? []).map((e: Expr) => this.safeEval(e)),
|
||
kw: Object.fromEntries(Object.entries(node.kw ?? {}).map(([k, e]) => [k, this.safeEval(e as Expr)])),
|
||
};
|
||
}
|
||
|
||
private async exec(node: Node) {
|
||
switch (node.op) {
|
||
case "nani_exit":
|
||
this.stack = [];
|
||
this.cur = { label: "", path: [0] };
|
||
return;
|
||
case "nani_add_choice":
|
||
(this.store.__nani_choices ??= []).push({ text: node.what, tid: node.tid,
|
||
target: node.target, handler: node.handler });
|
||
this.advance();
|
||
return;
|
||
case "nani_clear_choice":
|
||
this.store.__nani_choices = (this.store.__nani_choices ?? []).filter(
|
||
(choice: any) => node.handler !== "*" && choice.handler !== node.handler);
|
||
this.advance();
|
||
return;
|
||
case "nani_menu": {
|
||
const choices: { text: string; tid: string; target: string }[] = this.store.__nani_choices ?? [];
|
||
if (!choices.length) throw new Error("Naninovel: Stop без доступных вариантов выбора");
|
||
this.interactionStart();
|
||
this.setSkip(false);
|
||
const target = await this.wait<string>((done) => {
|
||
this.menuEl.textContent = "";
|
||
for (const choice of choices) {
|
||
const button = el("button", "pg-choice");
|
||
renderText(button, parseText(this.interp(this.tr.line(choice.tid, choice.text))), Infinity, this.baseSize);
|
||
button.onclick = (event) => { event.stopPropagation(); done(choice.target); };
|
||
this.menuEl.append(button);
|
||
}
|
||
this.menuEl.classList.remove("pg-hidden");
|
||
});
|
||
this.menuEl.classList.add("pg-hidden");
|
||
this.store.__nani_choices = [];
|
||
this.jump(target);
|
||
return;
|
||
}
|
||
case "nani_hide_characters":
|
||
this.renderer.commit();
|
||
for (const image of this.renderer.snapshot()) {
|
||
if (image.tag.startsWith("nani-char-")) this.renderer.hide(image.tag);
|
||
}
|
||
if (node.duration) await this.renderer.transition({ t: "dissolve", d: node.duration });
|
||
this.advance();
|
||
return;
|
||
case "say":
|
||
await this.say(node);
|
||
this.advance();
|
||
return;
|
||
case "scene":
|
||
case "show":
|
||
case "hide": {
|
||
if (node.with) this.renderer.commit();
|
||
if (node.op === "scene") {
|
||
this.renderer.scene();
|
||
this.clearNvl();
|
||
}
|
||
if (node.op === "hide") this.renderer.hide(node.tagExpr ? pyStr(this.safeEval(node.tagExpr)).split(" ")[0] : node.tag);
|
||
else if (node.img) this.showNode(node);
|
||
if (node.with) await this.renderer.transition(this.transition(node.with));
|
||
this.advance();
|
||
return;
|
||
}
|
||
case "with":
|
||
await this.renderer.transition(this.transition(node.t));
|
||
this.advance();
|
||
return;
|
||
case "play":
|
||
case "queue": {
|
||
const files = node.files.map((f: Expr) => this.audioFile(f)).filter(Boolean) as string[];
|
||
if (files.length) {
|
||
if (node.op === "play")
|
||
this.audio.play(node.ch, files, { loop: node.loop, fadein: node.fadein, fadeout: node.fadeout,
|
||
ifChanged: node.ifChanged });
|
||
else this.audio.queue(node.ch, files, node.loop);
|
||
}
|
||
this.advance();
|
||
return;
|
||
}
|
||
case "stop":
|
||
this.audio.stop(node.ch, node.fadeout ?? 0);
|
||
this.advance();
|
||
return;
|
||
case "voice":
|
||
if (node.file) {
|
||
const f = this.audioFile(node.file);
|
||
if (f) this.audio.play("voice", [f], { loop: false });
|
||
}
|
||
this.advance();
|
||
return;
|
||
case "pause": {
|
||
const d = node.d ? Number(this.safeEval(node.d)) : null;
|
||
await this.pause(d);
|
||
this.advance();
|
||
return;
|
||
}
|
||
case "menu":
|
||
return this.menu(node);
|
||
case "if": {
|
||
const i = node.branches.findIndex((b: any) => b.c === null || truthy(this.safeEval(b.c)));
|
||
if (i >= 0) this.enter(i);
|
||
else this.advance();
|
||
return;
|
||
}
|
||
case "while":
|
||
if (truthy(this.safeEval(node.c))) this.enter(0);
|
||
else this.advance();
|
||
return;
|
||
case "jump":
|
||
return this.jump(node.target ?? pyStr(this.safeEval(node.expr)));
|
||
case "call":
|
||
return this.call(node.target ?? pyStr(this.safeEval(node.expr)), node.args);
|
||
case "call_screen": {
|
||
const { args, kw } = this.screenArgs(node);
|
||
this.interactionStart();
|
||
this.setSkip(false);
|
||
const value = await this.wait<any>((done) => this.screens.show(node.name, args, kw, { call: true, onReturn: done }));
|
||
this.screens.hideCall(node.name);
|
||
this.store._return = value ?? null;
|
||
this.advance();
|
||
return;
|
||
}
|
||
case "show_screen": {
|
||
const { args, kw } = this.screenArgs(node);
|
||
this.screens.show(node.name, args, kw);
|
||
this.advance();
|
||
return;
|
||
}
|
||
case "hide_screen":
|
||
this.screens.hide(node.name);
|
||
this.advance();
|
||
return;
|
||
case "camera":
|
||
this.renderer.camera(node.at ?? []);
|
||
this.advance();
|
||
return;
|
||
case "return":
|
||
this.doReturn();
|
||
return;
|
||
case "exec":
|
||
try {
|
||
if (this.py.mayBlock(node.code)) await this.drive(this.py.execG(node.code));
|
||
else this.py.exec(node.code);
|
||
} catch (err) {
|
||
if (err instanceof JumpSignal) throw err;
|
||
throw err;
|
||
}
|
||
this.advance();
|
||
return;
|
||
case "window":
|
||
this.windowShown = node.mode !== "hide";
|
||
if (!this.windowShown) this.textbox.classList.add("pg-hidden");
|
||
this.advance();
|
||
return;
|
||
case "nvl":
|
||
if (node.a === "clear") this.clearNvl();
|
||
else this.nvlEl.classList.toggle("pg-hidden", node.a === "hide");
|
||
this.advance();
|
||
return;
|
||
default:
|
||
this.advance();
|
||
}
|
||
}
|
||
|
||
private showNode(node: Node) {
|
||
const img = this.imageFromSpec(node.img);
|
||
if (!img) {
|
||
console.warn("[pgvn] нет изображения", node.img);
|
||
return;
|
||
}
|
||
const tag = node.as ?? (node.img.name ? node.img.name[0] : img.name[0]);
|
||
for (let n = 1; n <= img.name.length; n++) this.seenImages.add(img.name.slice(0, n).join(" "));
|
||
const zorder = node.zorder ? Number(this.safeEval(node.zorder)) : undefined;
|
||
this.renderer.show(img, tag, node.at ?? [], { zorder, behind: node.behind });
|
||
}
|
||
|
||
private transition(t: any) {
|
||
if (t && t.t === "ref") return this.game.transitions[t.name] ?? { t: "dissolve", d: 0.5 };
|
||
return t;
|
||
}
|
||
|
||
private audioFile(e: Expr): string | null {
|
||
if (e.k === "name" && this.game.audio[e.id] && !(e.id in this.store)) return this.game.audio[e.id];
|
||
const v = this.safeEval(e);
|
||
if (typeof v !== "string") return null;
|
||
return this.game.audio[v] ?? v;
|
||
}
|
||
|
||
// --- interactions -----------------------------------------------------------------
|
||
|
||
private interactionStart() {
|
||
this.renderer.commit();
|
||
this.interactions++;
|
||
if (this.interactions % 15 === 0) void this.save("auto", true);
|
||
this.preloadAhead();
|
||
}
|
||
|
||
private preloadAhead() {
|
||
const block = this.labels.has(this.cur.label) ? this.blockFor(this.cur) : [];
|
||
const start = this.cur.path[this.cur.path.length - 1];
|
||
for (const n of block.slice(start + 1, start + 12)) {
|
||
if ((n.op === "show" || n.op === "scene") && n.img?.name) this.renderer.preload(this.resolveImage(n.img.name)?.def ?? null);
|
||
}
|
||
}
|
||
|
||
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
|
||
if (node.whoObj) {
|
||
const speaker = node.whoObj;
|
||
return { char: { color: speaker.color ?? undefined }, name: this.interp(this.tr.str(speaker.name ?? "")), mode: "adv" };
|
||
}
|
||
let who: string | undefined = node.who;
|
||
if (node.whoExpr) {
|
||
const value = this.safeEval(node.whoExpr);
|
||
who = typeof value === "string" ? value : undefined;
|
||
}
|
||
if (this.game.engine === "naninovel" && node.whoStr !== undefined) {
|
||
const entry = this.game.naniResources?.CharacterNames?.[node.whoStr]?.[0];
|
||
return { char: {}, name: this.interp(entry ? this.tr.line(entry.tid, entry.text) : this.tr.str(node.whoStr)), mode: "adv" };
|
||
}
|
||
if (node.whoStr !== undefined) return { char: {}, name: this.interp(this.tr.str(node.whoStr)), mode: "adv" };
|
||
if (!who || who === "narrator") return { char: {}, name: "", mode: "adv" };
|
||
if (who === "centered" || who === "vcentered") return { char: {}, name: "", mode: "centered" };
|
||
if (who === "extend") return { char: this.lastSay?.char ?? {}, name: this.lastSay?.name ?? "", mode: "extend" };
|
||
if (who === "nvl") return { char: { nvl: true }, name: "", mode: "nvl" };
|
||
const char = this.game.characters[who];
|
||
if (!char) {
|
||
const v = this.store[who];
|
||
if (v && typeof v === "object" && v.__character) {
|
||
return { char: { color: v.color ?? undefined }, name: this.interp(this.tr.str(v.name ?? who)), mode: "adv" };
|
||
}
|
||
return { char: {}, name: typeof v === "string" ? v : who, mode: "adv" };
|
||
}
|
||
let name = this.tr.str(char.name ?? "");
|
||
if (char.nameExpr) name = this.tr.str(pyStr(this.safeEval(char.nameExpr)));
|
||
name = this.interp(name);
|
||
return { char, name: `${char.whoPrefix ?? ""}${name}${char.whoSuffix ?? ""}`, mode: char.nvl ? "nvl" : "adv" };
|
||
}
|
||
|
||
private interp(s: string): string {
|
||
if (this.game.engine === "naninovel") return naniText(s, this.game.naniExpressions ?? {},
|
||
(name) => Object.hasOwn(this.store, name) ? this.store[name] : undefined);
|
||
return interpolate(s, (expr) => this.lookupPath(expr), (v) => this.tr.str(v));
|
||
}
|
||
|
||
private lookupPath(expr: string): any {
|
||
const m = expr.match(/^[A-Za-z_][\w]*/);
|
||
if (!m) return "";
|
||
let v = this.py.lookup(m[0]);
|
||
const rest = expr.slice(m[0].length);
|
||
for (const part of rest.matchAll(/\.(\w+)|\[(\d+|'[^']*'|"[^"]*")\]/g)) {
|
||
if (v === null || v === undefined) return "";
|
||
const k = part[1] ?? JSON.parse(part[2].replace(/^'|'$/g, '"'));
|
||
v = v instanceof PyDict ? v.get(k) : v[k];
|
||
}
|
||
return v ?? "";
|
||
}
|
||
|
||
private async say(node: Node, inMenu = false) {
|
||
const { char, name, mode } = this.characterFor(node);
|
||
if (node.attrs && char.image && this.renderer.isShown(char.image)) {
|
||
const img = this.resolveImage([char.image, ...node.attrs]);
|
||
if (img) this.renderer.show(img, char.image, []);
|
||
}
|
||
if (!inMenu) this.interactionStart();
|
||
if (node.with) await this.renderer.transition(this.transition(node.with));
|
||
|
||
let raw = node.whatExpr
|
||
? this.interp(pyStr(this.safeEval(node.whatExpr)))
|
||
: this.interp(this.tr.line(node.tid, node.what));
|
||
raw = `${char.whatPrefix ?? ""}${raw}${char.whatSuffix ?? ""}`;
|
||
let prefixLen = 0;
|
||
if (mode === "extend" && this.lastSay) {
|
||
prefixLen = [...plainText(parseText(this.lastSay.text))].length;
|
||
raw = this.lastSay.text + raw;
|
||
}
|
||
const parsed = parseText(raw);
|
||
this.lastSay = { name, text: raw, color: char.color, char };
|
||
if (mode !== "extend") this.history.push({ name, text: plainText(parsed) });
|
||
else if (this.history.length) this.history[this.history.length - 1].text = plainText(parsed);
|
||
if (this.history.length > 300) this.history.shift();
|
||
|
||
let target: HTMLElement;
|
||
if (mode === "centered") {
|
||
this.textbox.classList.add("pg-hidden");
|
||
this.centeredEl.classList.remove("pg-hidden");
|
||
target = this.centeredEl;
|
||
} else if (mode === "nvl") {
|
||
this.textbox.classList.add("pg-hidden");
|
||
this.nvlEl.classList.remove("pg-hidden");
|
||
const line = el("div", "pg-nvl-line");
|
||
if (name) {
|
||
const who = el("span", "pg-nvl-name", name);
|
||
if (char.color) who.style.color = char.color;
|
||
line.append(who);
|
||
}
|
||
const what = el("span", "pg-nvl-what");
|
||
line.append(what);
|
||
this.nvlEl.append(line);
|
||
this.nvlLines.push(line);
|
||
target = what;
|
||
} else {
|
||
this.centeredEl.classList.add("pg-hidden");
|
||
this.nvlEl.classList.add("pg-hidden");
|
||
this.textbox.classList.remove("pg-hidden");
|
||
this.namebox.textContent = name;
|
||
this.namebox.classList.toggle("pg-hidden", !name);
|
||
this.namebox.style.color = char.color ?? "";
|
||
this.textEl.style.color = char.whatColor ?? "";
|
||
this.textEl.style.fontStyle = char.whatItalic ? "italic" : "";
|
||
this.textEl.style.fontWeight = char.whatBold ? "bold" : "";
|
||
target = this.textEl;
|
||
}
|
||
|
||
await this.typewrite(target, parsed, Math.max(prefixLen, fastPoint(parsed)), inMenu);
|
||
if (inMenu || node.interact === false) return;
|
||
if (!parsed.noWait) await this.waitAdvance(parsed.length);
|
||
this.audio.stop("voice");
|
||
this.centeredEl.classList.add("pg-hidden");
|
||
}
|
||
|
||
private async typewrite(target: HTMLElement, parsed: ReturnType<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);
|
||
}
|
||
|
||
private fail(error: any) {
|
||
this.gen++;
|
||
this.wakeAll();
|
||
this.running = false;
|
||
this.screens.suspend();
|
||
this.notify(`Ошибка плеера: ${error?.message ?? String(error)}`);
|
||
}
|
||
|
||
// --- session control --------------------------------------------------------------
|
||
|
||
private restart(setup: () => void) {
|
||
this.gen++;
|
||
this.wakeAll();
|
||
this.onAdvance = null;
|
||
this.skipTick = null;
|
||
this.screens.hideCalls();
|
||
this.closeScreen();
|
||
this.menuEl.classList.add("pg-hidden");
|
||
this.centeredEl.classList.add("pg-hidden");
|
||
this.clearNvl();
|
||
this.setSkip(false);
|
||
setup();
|
||
void this.loop();
|
||
}
|
||
|
||
start() {
|
||
this.showMainMenu();
|
||
}
|
||
|
||
/** Preview from a given line (translation editor): game variables keep their defaults. */
|
||
startAt(nodeId: string) {
|
||
const at = this.findNode(nodeId);
|
||
if (!at) {
|
||
this.start();
|
||
this.notify("Строка не найдена в этой версии");
|
||
return;
|
||
}
|
||
this.newGame(at);
|
||
// rebuild the picture: replay the visual statements of the label that come before the line
|
||
const body = this.labels.get(at.label)!.label.body;
|
||
for (const n of body.slice(0, at.path[0])) {
|
||
if (n.op === "scene") {
|
||
this.renderer.scene();
|
||
if (n.img) this.showNode(n);
|
||
} else if (n.op === "show") this.showNode(n);
|
||
else if (n.op === "hide") this.renderer.hide(n.tag);
|
||
}
|
||
this.renderer.commit();
|
||
this.notify("Предпросмотр с выбранной строки — переменные игры по умолчанию");
|
||
}
|
||
|
||
private findNode(id: string): Frame | null {
|
||
const visit = (nodes: Node[], label: string, prefix: number[]): Frame | null => {
|
||
for (let i = 0; i < nodes.length; i++) {
|
||
const n = nodes[i];
|
||
const path = [...prefix, i];
|
||
if (n.id === id || (n.op === "menu" && n.say?.id === id)) return { label, path };
|
||
const children: [Node[], number][] =
|
||
n.op === "if" ? n.branches.map((b: any, k: number) => [b.body, k])
|
||
: n.op === "menu" ? n.items.map((it: any, k: number) => [it.body, k])
|
||
: n.op === "while" ? [[n.body, 0]] : [];
|
||
for (const [body, k] of children) {
|
||
const hit = visit(body, label, [...path, k]);
|
||
if (hit) return hit;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
for (const { label } of this.labels.values()) {
|
||
const hit = visit(label.body, label.name, []);
|
||
if (hit) return hit;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private newGame(at?: Frame) {
|
||
this.restart(() => {
|
||
this.audio.stopAll();
|
||
this.renderer.scene();
|
||
this.renderer.camera([]);
|
||
this.renderer.commit();
|
||
this.newStore();
|
||
this.stack = [];
|
||
this.history = [];
|
||
this.lastSay = null;
|
||
this.textbox.classList.add("pg-hidden");
|
||
this.cur = at ?? { label: this.game.entry, path: [0] };
|
||
this.markSeen(this.cur.label);
|
||
this.settle();
|
||
});
|
||
}
|
||
|
||
private snapshotState(): SaveData {
|
||
const anchor = (f: Frame): Frame => {
|
||
const label = this.labels.get(f.label)?.label;
|
||
let body = label?.body;
|
||
for (let i = 0; body && i < f.path.length - 1; i += 2) {
|
||
const n = body[f.path[i]];
|
||
if (!n) { body = undefined; break; }
|
||
body = n.op === "if" ? n.branches[f.path[i + 1]]?.body
|
||
: n.op === "menu" ? n.items[f.path[i + 1]]?.body : n.body;
|
||
}
|
||
return { ...f, path: [...f.path], nodeId: body?.[f.path[f.path.length - 1]]?.id };
|
||
};
|
||
return {
|
||
v: 1,
|
||
revision: this.manifest.revision,
|
||
cur: anchor(this.cur),
|
||
stack: this.stack.map((f) => ({ ...anchor(f), saved: encodeValue(f.saved) })),
|
||
store: encodeValue(this.store),
|
||
screens: encodeValue(this.screens.snapshot()),
|
||
scene: this.renderer.snapshot(),
|
||
registeredImages: { ...this.registeredImages },
|
||
music: this.audio.current("music"),
|
||
window: this.windowShown,
|
||
camera: this.renderer.cameraAt,
|
||
text: this.lastSay ? `${this.lastSay.name ? this.lastSay.name + ": " : ""}${plainText(parseText(this.lastSay.text))}` : "",
|
||
};
|
||
}
|
||
|
||
private slotCache = new Map<string, { title: string; updated_at: string }>();
|
||
private slotsRead = false;
|
||
|
||
/** Slot metadata for the File* actions of a game's own save screens (read once, then kept up to date). */
|
||
private async refreshSlots() {
|
||
try {
|
||
this.slotCache = new Map((await this.saves.list()).map((meta) => [meta.slot, meta]));
|
||
} catch { /* storage unavailable: slots look empty */ }
|
||
this.screens.refresh();
|
||
}
|
||
|
||
private async saveFileSlot(slot: string, confirm: boolean) {
|
||
if (confirm && this.slotCache.has(slot) && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
|
||
await this.save(slot);
|
||
await this.refreshSlots();
|
||
}
|
||
|
||
private async deleteFileSlot(slot: string, confirm: boolean) {
|
||
if (!this.slotCache.has(slot)) return;
|
||
if (confirm && !(await this.confirmAsk("Удалить сохранение?", "Удалить"))) return;
|
||
await this.saves.remove(slot).catch(() => {});
|
||
await this.refreshSlots();
|
||
}
|
||
|
||
private async save(slot: string, silent = false) {
|
||
if (!this.running || !this.cur.label) return;
|
||
const data = this.snapshotState();
|
||
const title = (data.text ?? "").slice(0, 120) || this.cur.label;
|
||
try {
|
||
await this.saves.put(slot, title, data);
|
||
if (!silent) this.notify("Сохранено");
|
||
} catch (err) {
|
||
if (!silent) this.notify(`Не удалось сохранить: ${(err as Error).message}`);
|
||
}
|
||
}
|
||
|
||
private async load(slot: string) {
|
||
const data = await this.saves.get(slot);
|
||
if (!data) {
|
||
this.notify("Сохранение не найдено");
|
||
return;
|
||
}
|
||
if (!this.labels.has(data.cur.label)) {
|
||
this.notify("Сохранение не подходит к этой версии игры");
|
||
return;
|
||
}
|
||
if (data.revision && data.revision !== this.manifest.revision) {
|
||
// IDs repair positions, but cannot prove compatibility of variables/game logic.
|
||
const frames = [data.cur, ...data.stack];
|
||
const mapped = frames.map((f) => f.nodeId ? this.findNode(f.nodeId) : null);
|
||
if (mapped.some((f) => !f)) {
|
||
this.notify("После изменения игры позиция сохранения не найдена. Используйте прежнюю ревизию или начните заново");
|
||
return;
|
||
}
|
||
if (!(await this.confirmAsk("Игра изменилась. Позиции найдены по ID, но состояние переменных может быть несовместимо. Попробовать загрузить?", "Загрузить"))) return;
|
||
frames.forEach((f, i) => Object.assign(f, mapped[i]));
|
||
}
|
||
if (!data.revision && !(await this.confirmAsk("Старое сохранение не содержит ревизию игры. После изменения скрипта его позиции могут быть неверны. Попробовать загрузить?", "Загрузить"))) return;
|
||
this.replayStash = null;
|
||
this.restart(() => this.applySave(data));
|
||
}
|
||
|
||
/** Put the game into the state described by a save (also used to come back from a replay). */
|
||
private applySave(data: SaveData) {
|
||
this.resetStore();
|
||
for (const [name, definition] of Object.entries(data.registeredImages ?? {})) this.registerImage(name, definition);
|
||
Object.assign(this.store, decodeValue(data.store));
|
||
this.screens.restore(decodeValue(data.screens ?? []));
|
||
this.renderer.scene();
|
||
for (const s of data.scene) {
|
||
const img = this.resolveImage(s.name);
|
||
if (img) this.renderer.show(img, s.tag, s.at, { zorder: s.zorder });
|
||
}
|
||
this.renderer.commit();
|
||
this.audio.stopAll();
|
||
if (data.music) this.audio.play("music", [data.music.file], { loop: data.music.loop, fadein: 0.5 });
|
||
this.windowShown = data.window;
|
||
this.renderer.camera(data.camera ?? []);
|
||
this.stack = data.stack.map((f) => ({ ...f, saved: decodeValue(f.saved) }));
|
||
this.cur = data.cur;
|
||
this.lastSay = null;
|
||
this.history = [];
|
||
}
|
||
|
||
/** Gallery replay: run a label with the given variables, then return to exactly where the game was. */
|
||
private startReplay(label: string, scope: Record<string, any> | null) {
|
||
if (!this.labels.has(label)) {
|
||
this.notify(`Метка «${label}» не найдена`);
|
||
return;
|
||
}
|
||
if (!this.replayStash) this.replayStash = this.snapshotState();
|
||
this.restart(() => {
|
||
for (const [key, value] of Object.entries(scope ?? {})) this.store[key] = value;
|
||
this.store._in_replay = label;
|
||
this.stack = [];
|
||
this.cur = { label: "", path: [0] };
|
||
this.guardedJump(label);
|
||
});
|
||
}
|
||
|
||
private endReplay() {
|
||
const stash = this.replayStash;
|
||
if (!stash) return;
|
||
this.replayStash = null;
|
||
this.restart(() => this.applySave(stash));
|
||
}
|
||
|
||
private setSkip(on: boolean) {
|
||
this.skip = on;
|
||
this.renderer.fast = on;
|
||
this.quick?.querySelector("[data-act=skip]")?.classList.toggle("pg-on", on);
|
||
this.skipTick?.();
|
||
}
|
||
|
||
private setAuto(on: boolean) {
|
||
this.auto = on;
|
||
this.quick.querySelector("[data-act=auto]")?.classList.toggle("pg-on", on);
|
||
this.skipTick?.();
|
||
}
|
||
|
||
// --- UI -------------------------------------------------------------------------
|
||
|
||
private buildUI() {
|
||
const { ui } = this.game;
|
||
const [w, h] = this.game.resolution;
|
||
const stage = this.renderer.stage;
|
||
const px = (v: any, total: number, fallback: number) =>
|
||
typeof v === "number" ? (Number.isInteger(v) && v > 1 ? v : v * total) : fallback;
|
||
stage.style.setProperty("--pg-font", `${this.baseSize}px`);
|
||
stage.style.setProperty("--pg-name-font", `${Number(ui.name_text_size) || Math.round(this.baseSize * 1.2)}px`);
|
||
if (ui.accent_color) stage.style.setProperty("--pg-accent", ui.accent_color);
|
||
if (ui.text_color) stage.style.setProperty("--pg-text", ui.text_color);
|
||
this.loadFont("pg-game-font", ui.text_font);
|
||
this.loadFont("pg-game-name-font", ui.name_text_font ?? ui.text_font);
|
||
this.applyLocaleFont();
|
||
|
||
this.textbox = el("div", "pg-textbox pg-hidden");
|
||
const boxH = px(ui.textbox_height, h, Math.round(h * 0.257));
|
||
this.textbox.style.height = `${boxH}px`;
|
||
if (ui.textbox) {
|
||
const url = this.fileUrl(ui.textbox);
|
||
if (url) this.textbox.style.backgroundImage = `url("${url}")`;
|
||
this.textbox.classList.add("pg-textbox-image");
|
||
}
|
||
this.namebox = el("div", "pg-namebox");
|
||
this.namebox.style.left = `${px(ui.name_xpos, w, Math.round(w * 0.1875))}px`;
|
||
this.namebox.style.transform = "translateX(-50%)";
|
||
if (!ui.name_xpos) this.namebox.style.transform = "";
|
||
this.textEl = el("div", "pg-text");
|
||
this.textEl.style.left = `${px(ui.dialogue_xpos, w, Math.round(w * 0.21))}px`;
|
||
this.textEl.style.top = `${px(ui.dialogue_ypos, boxH, Math.round(boxH * 0.18))}px`;
|
||
this.textEl.style.width = `${px(ui.dialogue_width, w, Math.round(w * 0.58))}px`;
|
||
this.ctc = el("span", "pg-ctc", "▼");
|
||
this.textbox.append(this.namebox, this.textEl);
|
||
|
||
this.quick = el("div", "pg-quick");
|
||
const actions: [string, string, () => void][] = [
|
||
["history", "История", () => this.openHistory()],
|
||
["skip", "Пропуск", () => this.setSkip(!this.skip)],
|
||
["auto", "Авто", () => this.setAuto(!this.auto)],
|
||
["save", "Сохр.", () => this.openSaves("save")],
|
||
["load", "Загр.", () => this.openSaves("load")],
|
||
["qsave", "Б.Сохр.", () => void this.save("quick")],
|
||
["qload", "Б.Загр.", () => void this.load("quick")],
|
||
["prefs", "Настройки", () => this.openSettings()],
|
||
["menu", "Меню", () => this.openGameMenu()],
|
||
];
|
||
for (const [act, label, fn] of actions) {
|
||
const b = el("button", "", label);
|
||
b.dataset.act = act;
|
||
b.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
fn();
|
||
};
|
||
this.quick.append(b);
|
||
}
|
||
this.textbox.append(this.quick);
|
||
|
||
this.menuEl = el("div", "pg-menu pg-hidden");
|
||
this.nvlEl = el("div", "pg-nvl pg-hidden");
|
||
if (ui.nvl) {
|
||
const url = this.fileUrl(ui.nvl);
|
||
if (url) this.nvlEl.style.backgroundImage = `url("${url}")`;
|
||
}
|
||
this.centeredEl = el("div", "pg-centered pg-hidden");
|
||
this.screen = el("div", "pg-screen pg-hidden");
|
||
this.notifyEl = el("div", "pg-notify pg-hidden");
|
||
stage.append(this.nvlEl, this.textbox, this.centeredEl, this.menuEl, this.screens.layer, this.screen, this.notifyEl);
|
||
|
||
// touch-friendly menu button outside the scaled stage
|
||
const hud = el("button", "pg-hud-btn", "☰");
|
||
hud.title = "Меню";
|
||
hud.setAttribute("aria-label", "Меню");
|
||
hud.onclick = (ev) => {
|
||
ev.stopPropagation();
|
||
if (this.screen.classList.contains("pg-main")) return;
|
||
if (this.screen.classList.contains("pg-hidden")) this.openGameMenu();
|
||
else this.dismissScreen();
|
||
};
|
||
this.container.append(hud);
|
||
|
||
stage.addEventListener("click", (ev) => {
|
||
if (!this.screen.classList.contains("pg-hidden")) return;
|
||
if ((ev.target as HTMLElement).closest(".pg-menu, .pg-quick, .pg-sbtn, .pg-sframe, .pg-swindow, .pg-sviewport, .pg-sbar, .pg-smodal")) return;
|
||
this.advanceInput();
|
||
});
|
||
stage.addEventListener("wheel", (ev) => {
|
||
if (ev.deltaY < 0 && this.screen.classList.contains("pg-hidden") && this.running) this.openHistory();
|
||
}, { passive: true });
|
||
}
|
||
|
||
/** A translation may bring its own font when the game's fonts lack its glyphs. */
|
||
private applyLocaleFont() {
|
||
this.renderer.stage.classList.remove("pg-locale-font");
|
||
if (this.tr.font) this.loadFont("pg-locale-font", this.tr.font);
|
||
}
|
||
|
||
private loadFont(family: string, file?: string) {
|
||
const url = file ? this.fileUrl(file) : null;
|
||
if (!url) return;
|
||
const face = new FontFace(family, `url("${url}")`);
|
||
face.load().then((f) => {
|
||
document.fonts.add(f);
|
||
this.renderer.stage.classList.add(family);
|
||
}).catch(() => {});
|
||
}
|
||
|
||
/** In-player confirmation (the native confirm() breaks fullscreen and gamepads). */
|
||
private confirmAsk(text: string, okLabel = "Ок"): Promise<boolean> {
|
||
return new Promise((resolve) => {
|
||
const back = el("div", "pg-dialog-back");
|
||
const box = el("div", "pg-dialog");
|
||
box.setAttribute("role", "dialog");
|
||
box.append(el("p", "", text));
|
||
const row = el("div", "pg-dialog-row");
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (e.key !== "Escape") return;
|
||
e.stopImmediatePropagation();
|
||
done(false);
|
||
};
|
||
const done = (v: boolean) => {
|
||
window.removeEventListener("keydown", onKey, true);
|
||
back.remove();
|
||
resolve(v);
|
||
};
|
||
const no = el("button", "", "Отмена");
|
||
no.onclick = () => done(false);
|
||
const yes = el("button", "pg-primary", okLabel);
|
||
yes.onclick = () => done(true);
|
||
window.addEventListener("keydown", onKey, true);
|
||
row.append(no, yes);
|
||
box.append(row);
|
||
back.append(box);
|
||
this.container.append(back);
|
||
yes.focus();
|
||
});
|
||
}
|
||
|
||
private advanceInput() {
|
||
if (this.skip) {
|
||
this.setSkip(false);
|
||
return;
|
||
}
|
||
if (!this.textbox.classList.contains("pg-ui-hidden")) this.onAdvance?.();
|
||
this.textbox.classList.remove("pg-ui-hidden");
|
||
}
|
||
|
||
private onKey = (ev: KeyboardEvent) => {
|
||
const screenOpen = !this.screen.classList.contains("pg-hidden");
|
||
if (ev.key === "Escape") {
|
||
if (this.screen.classList.contains("pg-main")) return;
|
||
if (screenOpen) this.dismissScreen();
|
||
else if (this.running) this.openGameMenu();
|
||
return;
|
||
}
|
||
if ((ev.target as HTMLElement)?.tagName === "INPUT") return;
|
||
if (screenOpen) return;
|
||
if (ev.key === " " || ev.key === "Enter") {
|
||
ev.preventDefault();
|
||
this.advanceInput();
|
||
} else if (ev.key === "Control" && !this.skip) {
|
||
this.setSkip(true);
|
||
} else if (ev.key === "Tab") {
|
||
ev.preventDefault();
|
||
this.setSkip(!this.skip);
|
||
} else if (ev.key === "a" || ev.key === "ф") {
|
||
this.setAuto(!this.auto);
|
||
} else if (ev.key === "h" || ev.key === "р") {
|
||
this.textbox.classList.toggle("pg-ui-hidden");
|
||
}
|
||
};
|
||
|
||
private onKeyUp = (ev: KeyboardEvent) => {
|
||
if (ev.key === "Control") this.setSkip(false);
|
||
};
|
||
|
||
private screenOnClose: (() => void) | null = null;
|
||
|
||
private openScreen(title: string, build: (body: HTMLElement) => void, onClose?: () => void,
|
||
unscaled = false) {
|
||
this.placeScreen(unscaled);
|
||
this.screen.textContent = "";
|
||
this.screen.classList.remove("pg-main");
|
||
this.container.classList.remove("pg-in-main");
|
||
this.screen.style.backgroundImage = "";
|
||
this.screenOnClose = onClose ?? null;
|
||
const head = el("div", "pg-screen-head");
|
||
head.append(el("h2", "", title));
|
||
const close = el("button", "pg-close", "✕");
|
||
close.setAttribute("aria-label", "Закрыть");
|
||
close.onclick = () => this.dismissScreen();
|
||
head.append(close);
|
||
const body = el("div", "pg-screen-body");
|
||
this.screen.append(head, body);
|
||
build(body);
|
||
this.screen.classList.remove("pg-hidden");
|
||
}
|
||
|
||
private dismissScreen() {
|
||
const fn = this.screenOnClose;
|
||
if (fn) fn();
|
||
else this.closeScreen();
|
||
}
|
||
|
||
private closeScreen() {
|
||
this.screenOnClose = null;
|
||
this.screen.classList.add("pg-hidden");
|
||
this.screen.classList.remove("pg-main");
|
||
this.container.classList.remove("pg-in-main");
|
||
this.screen.style.backgroundImage = "";
|
||
this.placeScreen(false);
|
||
}
|
||
|
||
private placeScreen(unscaled: boolean) {
|
||
this.screen.classList.toggle("pg-settings-screen", unscaled);
|
||
(unscaled ? this.container : this.renderer.stage).append(this.screen);
|
||
}
|
||
|
||
private showMainMenu() {
|
||
this.placeScreen(false);
|
||
this.running = false;
|
||
this.gen++;
|
||
this.wakeAll();
|
||
this.audio.stopAll();
|
||
this.textbox.classList.add("pg-hidden");
|
||
this.menuEl.classList.add("pg-hidden");
|
||
this.clearNvl();
|
||
this.screen.textContent = "";
|
||
this.screen.classList.add("pg-main");
|
||
this.container.classList.add("pg-in-main");
|
||
const bg = this.game.ui.mainMenu ? this.fileUrl(this.game.ui.mainMenu) : null;
|
||
if (bg) this.screen.style.backgroundImage = `url("${bg}")`;
|
||
const panel = el("div", "pg-main-panel");
|
||
panel.append(el("h1", "", this.game.title ?? "Новелла"));
|
||
const btn = (label: string, fn: () => void) => {
|
||
const b = el("button", "", label);
|
||
b.onclick = fn;
|
||
panel.append(b);
|
||
return b;
|
||
};
|
||
btn("Начать", () => this.newGame());
|
||
const cont = btn("Продолжить", () => void this.load("auto"));
|
||
cont.style.display = "none";
|
||
this.saves.list().then((list) => {
|
||
if (list.some((s) => s.slot === "auto")) cont.style.display = "";
|
||
}).catch(() => {});
|
||
btn("Загрузить", () => this.openSaves("load", true));
|
||
btn("Настройки", () => this.openSettings(true));
|
||
const langs = this.languages();
|
||
if (langs.length > 1) {
|
||
const select = this.languageSelect(langs, () => this.showMainMenu());
|
||
select.classList.add("pg-lang");
|
||
panel.append(select);
|
||
}
|
||
btn("В библиотеку", () => this.onExit());
|
||
this.screen.append(panel);
|
||
this.screen.classList.remove("pg-hidden");
|
||
}
|
||
|
||
private languages(): string[] {
|
||
return [this.game.sourceLang ?? "und", ...Object.keys(this.game.locales ?? {})];
|
||
}
|
||
|
||
private languageSelect(langs: string[], after: () => void): HTMLSelectElement {
|
||
const select = el("select", "pg-select");
|
||
for (const lang of langs) {
|
||
const o = el("option", "", languageName(this.game, lang) + progressLabel(this.game.locales?.[lang]?.progress));
|
||
o.value = lang;
|
||
o.selected = lang === this.tr.lang;
|
||
select.append(o);
|
||
}
|
||
select.onchange = () => void this.setLanguage(select.value).then(after);
|
||
return select;
|
||
}
|
||
|
||
private openGameMenu() {
|
||
this.openScreen("Меню", (body) => {
|
||
const list = el("div", "pg-menu-list");
|
||
const add = (label: string, fn: () => void) => {
|
||
const b = el("button", "", label);
|
||
b.onclick = fn;
|
||
list.append(b);
|
||
};
|
||
add("Вернуться", () => this.closeScreen());
|
||
add("История", () => this.openHistory());
|
||
add(this.skip ? "Выключить пропуск" : "Пропуск", () => { this.closeScreen(); this.setSkip(!this.skip); });
|
||
add(this.auto ? "Выключить авто" : "Автопрочтение", () => { this.closeScreen(); this.setAuto(!this.auto); });
|
||
add("Сохранить", () => this.openSaves("save"));
|
||
add("Загрузить", () => this.openSaves("load"));
|
||
add("Настройки", () => this.openSettings());
|
||
add("Главное меню", () => {
|
||
void this.confirmAsk("Выйти в главное меню? Несохранённый прогресс будет потерян.", "Выйти")
|
||
.then((yes) => yes && this.showMainMenu());
|
||
});
|
||
add("В библиотеку", () => this.onExit());
|
||
body.append(list);
|
||
});
|
||
}
|
||
|
||
private openHistory() {
|
||
this.openScreen("История", (body) => {
|
||
const list = el("div", "pg-history");
|
||
for (const h of this.history) {
|
||
const row = el("div", "pg-history-row");
|
||
if (h.name) row.append(el("b", "", h.name));
|
||
row.append(el("span", "", h.text));
|
||
list.append(row);
|
||
}
|
||
body.append(list);
|
||
requestAnimationFrame(() => (list.scrollTop = list.scrollHeight));
|
||
});
|
||
}
|
||
|
||
private openSaves(mode: "save" | "load", fromMain = false) {
|
||
this.openScreen(mode === "save" ? "Сохранить" : "Загрузить", (body) => {
|
||
const grid = el("div", "pg-slots");
|
||
body.append(grid);
|
||
const render = (list: SaveMeta[]) => {
|
||
grid.textContent = "";
|
||
const bySlot = new Map(list.map((s) => [s.slot, s]));
|
||
const slots = [...(mode === "load" ? ["auto", "quick"] : []),
|
||
...Array.from({ length: SLOTS }, (_, i) => String(i + 1))];
|
||
for (const slot of slots) {
|
||
const meta = bySlot.get(slot);
|
||
const card = el("button", "pg-slot");
|
||
const label = slot === "auto" ? "Авто" : slot === "quick" ? "Быстрое" : `Слот ${slot}`;
|
||
card.append(el("b", "", label));
|
||
card.append(el("span", "pg-slot-title", meta ? meta.title : "Пусто"));
|
||
if (meta) card.append(el("small", "", new Date(meta.updated_at).toLocaleString()));
|
||
card.disabled = mode === "load" && !meta;
|
||
card.onclick = async () => {
|
||
if (mode === "save") {
|
||
if (meta && !(await this.confirmAsk("Перезаписать сохранение?", "Перезаписать"))) return;
|
||
await this.save(slot);
|
||
render(await this.saves.list());
|
||
} else {
|
||
await this.load(slot);
|
||
}
|
||
};
|
||
grid.append(card);
|
||
}
|
||
};
|
||
this.saves.list().then(render).catch((e) => this.notify(String(e)));
|
||
}, fromMain ? () => this.showMainMenu() : undefined);
|
||
}
|
||
|
||
/** Save a change made in the settings screen: volumes go to this game or to everyone. */
|
||
private persistSettings(kind: "audio" | "reading") {
|
||
const { music, sound, voice, cps, autoDelay } = this.settings;
|
||
if (kind === "audio" && this.ownVolumes) {
|
||
this.volumes.write({ music, sound, voice });
|
||
return;
|
||
}
|
||
if (kind === "audio") Object.assign(this.sharedSettings, { music, sound, voice });
|
||
else Object.assign(this.sharedSettings, { cps, autoDelay });
|
||
saveJSON("pgvn:settings", this.sharedSettings);
|
||
}
|
||
|
||
private useOwnVolumes(on: boolean, fromMain: boolean) {
|
||
if (!this.volumes.available) return;
|
||
const { music, sound, voice } = this.settings;
|
||
if (on) {
|
||
this.ownVolumes = true;
|
||
this.volumes.write({ music, sound, voice }); // starts from what is playing now
|
||
} else {
|
||
this.ownVolumes = false;
|
||
this.volumes.clear();
|
||
Object.assign(this.settings, { music: this.sharedSettings.music, sound: this.sharedSettings.sound,
|
||
voice: this.sharedSettings.voice });
|
||
this.audio.setVolumes(this.settings);
|
||
}
|
||
this.openSettings(fromMain); // redraw the sliders with the values now in effect
|
||
}
|
||
|
||
private openSettings(fromMain = false) {
|
||
this.openScreen("Настройки", (body) => {
|
||
const s = this.settings;
|
||
const layout = el("div", "pg-settings");
|
||
body.append(layout);
|
||
const section = (title: string, description: string) => {
|
||
const card = el("section", "pg-settings-card");
|
||
card.append(el("h3", "", title), el("p", "pg-settings-hint", description));
|
||
layout.append(card);
|
||
return card;
|
||
};
|
||
const row = (parent: HTMLElement, label: string, min: number, max: number, step: number, value: number,
|
||
fmt: (v: number) => string, set: (v: number) => void, kind: "audio" | "reading") => {
|
||
const r = el("label", "pg-setting");
|
||
const heading = el("span", "pg-setting-head");
|
||
const out = el("output", "pg-setting-val", fmt(value));
|
||
const input = el("input");
|
||
Object.assign(input, { type: "range", min, max, step, value: String(value) });
|
||
input.setAttribute("aria-label", label);
|
||
const update = () => {
|
||
out.value = fmt(Number(input.value));
|
||
input.setAttribute("aria-valuetext", out.value);
|
||
input.style.setProperty("--pg-progress", `${(Number(input.value) - min) / (max - min) * 100}%`);
|
||
};
|
||
update();
|
||
input.oninput = () => {
|
||
set(Number(input.value));
|
||
update();
|
||
this.persistSettings(kind);
|
||
this.audio.setVolumes(this.settings);
|
||
};
|
||
heading.append(el("span", "", label), out);
|
||
r.append(heading, input);
|
||
parent.append(r);
|
||
};
|
||
const audio = section("Звук", this.ownVolumes
|
||
? "Громкость только для этой игры. 0% — выключить канал."
|
||
: "Общая громкость всех игр. 0% — выключить канал.");
|
||
const percent = (v: number) => `${Math.round(v * 100)}%`;
|
||
if (this.volumes.available) {
|
||
const toggle = el("label", "pg-check");
|
||
const box = el("input");
|
||
Object.assign(box, { type: "checkbox", checked: this.ownVolumes });
|
||
box.onchange = () => this.useOwnVolumes(box.checked, fromMain);
|
||
toggle.append(box, el("span", "", "Своя громкость для этой игры"));
|
||
audio.append(toggle);
|
||
}
|
||
row(audio, "Музыка", 0, 1, 0.01, s.music, percent, (v) => (s.music = v), "audio");
|
||
row(audio, "Звуки", 0, 1, 0.01, s.sound, percent, (v) => (s.sound = v), "audio");
|
||
row(audio, "Голос", 0, 1, 0.01, s.voice, percent, (v) => (s.voice = v), "audio");
|
||
const reading = section("Чтение", "Настройте темп появления текста и автопрочтения.");
|
||
row(reading, "Скорость текста", 0, 150, 5, s.cps, (v) => (v === 0 ? "Мгновенно" : `${v} симв/с`), (v) => (s.cps = v), "reading");
|
||
row(reading, "Задержка авто", 0.3, 4, 0.1, s.autoDelay, (v) => `${v.toFixed(1)} с`, (v) => (s.autoDelay = v), "reading");
|
||
const display = section("Игра", "Изменения сохраняются автоматически в этом браузере.");
|
||
const graphics = section("Графика", "Локальный генератор настраивается в меню профиля → Улучшение графики. Результат подхватывается при следующем показе картинки.");
|
||
const imageToggle = (label: string, checked: boolean, apply: (value: boolean) => void) => {
|
||
const row = el("label", "pg-check"); const box = el("input");
|
||
Object.assign(box, { type: "checkbox", checked });
|
||
box.onchange = () => apply(box.checked); row.append(box, el("span", "", label)); graphics.append(row);
|
||
};
|
||
imageToggle("Показывать улучшенные изображения", this.liveImages?.enabled ?? false, (value) => {
|
||
if (!this.liveImages) return;
|
||
this.liveImages.enabled = value;
|
||
this.liveImages.save();
|
||
// Refresh shown scene for immediate original/result comparison.
|
||
const shown = this.renderer.snapshot();
|
||
for (const image of shown) { const resolved = this.resolveImage(image.name); if (resolved) this.renderer.show(resolved, image.tag, image.at, { zorder: image.zorder }); }
|
||
});
|
||
imageToggle("Улучшать по ходу игры", this.liveImages?.automatic ?? false, (value) => { if (this.liveImages) { this.liveImages.automatic = value; this.liveImages.save(); } });
|
||
const langs = this.languages();
|
||
if (langs.length > 1) {
|
||
const r = el("label", "pg-setting");
|
||
r.append(el("span", "", "Язык"), this.languageSelect(langs, () => {}));
|
||
display.append(r);
|
||
}
|
||
const fs = el("button", "pg-wide", document.fullscreenElement ? "Выйти из полного экрана" : "Полный экран");
|
||
fs.onclick = () => {
|
||
const action = document.fullscreenElement ? document.exitFullscreen() : this.root.requestFullscreen();
|
||
void action.then(() => {
|
||
fs.textContent = document.fullscreenElement ? "Выйти из полного экрана" : "Полный экран";
|
||
}).catch(() => this.notify("Не удалось изменить полноэкранный режим"));
|
||
};
|
||
fs.hidden = !document.fullscreenEnabled;
|
||
display.append(fs);
|
||
}, fromMain ? () => this.showMainMenu() : undefined, true);
|
||
}
|
||
}
|
||
|
||
function progressLabel(p: number | undefined): string {
|
||
return p !== undefined && p < 0.995 ? ` — ${Math.round(p * 100)}%` : "";
|
||
}
|
||
|
||
function childBlock(node: Node, branch: number): Node[] {
|
||
if (node.op === "if") return node.branches[branch].body;
|
||
if (node.op === "menu") return node.items[branch].body;
|
||
return node.body;
|
||
}
|
||
|
||
export type { ImageDef };
|