import { AudioSystem } from "./audio"; import type { SaveApi } from "./engine"; import type { Translator } from "./i18n"; import { NaniRuntime, type NaniPackage, type NaniState } from "./nani-runtime"; import { TomieGameplay } from "./tomie-gameplay"; import { TomieMinigame, type MiniState } from "./tomie-minigame"; import { TomieScene, type TomieMedia, type TomieVisual } from "./tomie-scene"; import { parseText, renderText } from "./text"; import type { Manifest } from "./types"; import "./tomie.css"; export interface TomieContent extends NaniPackage { media: TomieMedia; board_routes: Record; resources: Record>; } export interface TomieSave { format: "tomie/1"; revision?: string; runtime: NaniState; scene: TomieVisual; mini: MiniState | null; delay: number; history: string[]; ambient?: string; ambientElapsed?: number; lewdComment?: string; } const element = (name: K, parent?: HTMLElement, className = "") => { const node = document.createElement(name); node.className = className; parent?.append(node); return node; }; export class TomiePlayer { readonly vm: NaniRuntime; readonly scene: TomieScene; readonly gameplay: TomieGameplay; private shell: HTMLDivElement; private stage: HTMLDivElement; private backdrop: HTMLImageElement; private actors: HTMLDivElement; private video: HTMLVideoElement; private effects: HTMLDivElement; private dialogue: HTMLButtonElement; private author: HTMLDivElement; private text: HTMLDivElement; private choices: HTMLDivElement; private board: HTMLDivElement; private status: HTMLDivElement; private modal: HTMLDialogElement; private miniCanvas: HTMLCanvasElement; private miniControls: HTMLDivElement; private mini: TomieMinigame | null = null; private audio: AudioSystem; private delay = 0; private history: string[] = []; private gallery = new Set(); private profileKey: string; private timer = 0; private lastFrame = 0; private disposed = false; private autosave = false; private mode: "normal" | "auto" | "skip" = "normal"; private playbackElapsed = 0; private muted = false; private inputName = ""; private ambient = ""; private ambientElapsed = 0; private lewdComment = ""; private filters: SVGDefsElement; private filterId = `tomie-${Math.random().toString(36).slice(2)}`; constructor(root: HTMLElement, readonly content: TomieContent, private manifest: Manifest, private saves: SaveApi, private tr: Translator, private onExit: () => void) { this.profileKey = `pgvn:tomie:${manifest.versionId}`; this.scene = new TomieScene(content.media); this.shell = element("div", root, "tomie-root"); this.stage = element("div", this.shell, "tomie-stage"); const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.style.cssText = "position:absolute;width:0;height:0"; this.filters = document.createElementNS(svg.namespaceURI, "defs") as SVGDefsElement; svg.append(this.filters); this.stage.append(svg); this.backdrop = element("img", this.stage, "tomie-background"); this.backdrop.alt = ""; this.actors = element("div", this.stage, "tomie-actors"); this.video = element("video", this.stage, "tomie-video"); this.video.autoplay = true; this.video.loop = true; this.video.muted = true; this.video.playsInline = true; this.effects = element("div", this.stage, "tomie-effects"); this.dialogue = element("button", this.stage, "tomie-dialogue"); this.author = element("div", this.dialogue, "tomie-author"); this.text = element("div", this.dialogue, "tomie-text"); this.dialogue.onclick = () => this.next(); this.choices = element("div", this.stage, "tomie-choices"); this.board = element("div", this.stage, "tomie-board"); this.miniCanvas = element("canvas", this.stage, "tomie-mini"); this.miniCanvas.width = 1920; this.miniCanvas.height = 1080; this.miniControls = element("div", this.stage, "tomie-mini-controls"); this.miniCanvas.onclick = () => this.mini?.pulse(); const toolbar = element("div", this.shell, "tomie-toolbar"); for (const [title, action] of [ ["Сохранить", () => this.slots(false)], ["Загрузить", () => this.slots(true)], ["История", () => this.historyDialog()], ["Инвентарь", () => this.inventory()], ["Параметры", () => this.statistics()], ["Галерея", () => this.galleryDialog()], ["Авто", () => { this.mode = this.mode === "auto" ? "normal" : "auto"; }], ["Пропуск", () => { this.mode = this.mode === "skip" ? "normal" : "skip"; }], ["Звук", () => { this.muted = !this.muted; this.audio.setVolumes({ music: this.muted ? 0 : 0.7, sound: this.muted ? 0 : 0.8, voice: this.muted ? 0 : 1 }); }], ["Выйти", () => this.onExit()], ] as [string, () => void][]) this.button(toolbar, title, action); this.status = element("div", this.shell, "tomie-status"); this.modal = element("dialog", this.shell, "tomie-modal"); this.audio = new AudioSystem({ music: 0.7, sound: 0.8, voice: 1 }, (file) => this.url(file)); this.vm = new NaniRuntime(content, (kind, data) => this.event(kind, data), (tid, value) => this.tr.line(tid, this.tr.str(value)), { hasVideo: (name) => Object.hasOwn(content.media.videos, name.toLowerCase()), link: (url) => this.link(url), }); this.gameplay = new TomieGameplay(this.vm, content.board_routes); try { const profile = JSON.parse(localStorage.getItem(this.profileKey) ?? "{}"); this.vm.global = Object.fromEntries(Object.entries(profile.variables ?? {}).filter(([name]) => name.startsWith("G_"))); this.gallery = new Set(profile.gallery ?? []); } catch { /* storage unavailable or old profile */ } document.addEventListener("keydown", this.key); this.timer = requestAnimationFrame(this.frame); } start(): void { this.vm.start(); this.render(); } destroy(): void { this.disposed = true; cancelAnimationFrame(this.timer); document.removeEventListener("keydown", this.key); this.audio.stopAll(); this.video.pause(); this.video.removeAttribute("src"); this.video.load(); this.shell.remove(); } private url(path: string): string { const file = this.manifest.files[path]; if (!file) throw new Error("Tomie: отсутствует файл " + path); return `/api/versions/${this.manifest.versionId}/files/${file.hash}`; } private button(parent: HTMLElement, title: string, action: () => void, disabled = false): HTMLButtonElement { const button = element("button", parent); button.type = "button"; button.textContent = title; button.disabled = disabled; button.onclick = () => { try { void Promise.resolve(action()).catch((error) => this.fail(error)); } catch (error) { this.fail(error); } }; return button; } private rich(node: HTMLElement, source: string): void { const safe = source.replaceAll("{", "{{").replaceAll("[br]", "\n").replaceAll("\\n", "\n") .replace(/<(\/?)(b|i|u|s)>/gi, "{$1$2}").replace(/<(color|size)=([^<>]+)>/gi, "{$1=$2}") .replace(/<\/(color|size)>/gi, "{/$1}"); renderText(node, parseText(safe), Infinity, 25); } private plain(source: string): string { return source.replace(/<[^>]*>/g, "").replaceAll("[br]", "\n"); } private dictionary(collection: string, key: string): string { const entries = this.content.resources[collection]?.[key]; return entries?.length ? entries.map((entry) => this.tr.line(entry.tid, entry.text)).join(" · ") : key; } private paused(): boolean { return this.modal.open || document.hidden || !document.hasFocus(); } private next(): void { if (this.paused() || this.mini || !this.scene.state.inputEnabled) return; if (["text", "timer"].includes(this.vm.waiting)) { this.delay = 0; this.vm.advance(); this.render(); } } private key = (event: KeyboardEvent) => { if (this.modal.open || event.repeat || !["Space", "Enter"].includes(event.code)) return; event.preventDefault(); if (this.mini) this.mini.pulse(); else this.next(); }; private frame = (time: number) => { if (this.disposed) return; const delta = this.lastFrame ? Math.min(0.1, (time - this.lastFrame) / 1000) : 0; this.lastFrame = time; const paused = this.paused(); if (this.scene.state.video && this.scene.state.video.speed > 0 && !paused) void this.video.play().catch(() => {}); else this.video.pause(); if (!paused) { if (Number(this.vm.variables.Control_Progress ?? 0) === 1) { this.ambientElapsed += delta; if (this.ambientElapsed >= 8) { this.ambientElapsed = 0; this.refreshAmbient(); this.renderEffects(); } } if (this.mini) { this.mini.update(delta); this.drawMini(); } else if (["timer", "timed_choice"].includes(this.vm.waiting)) { this.delay -= delta; if (this.delay <= 0) { this.vm.advance(); this.render(); } } else if (this.vm.waiting === "text" && this.mode !== "normal") { this.playbackElapsed += delta; if (this.playbackElapsed > (this.mode === "skip" ? 0.07 : 1.5 + this.scene.state.text.length / 30)) this.next(); } if (this.autosave && ["text", "board", "choice"].includes(this.vm.waiting)) { this.autosave = false; void this.writeSave("auto").catch((error) => this.fail(error)); } } this.timer = requestAnimationFrame(this.frame); }; private event(kind: string, data: Record): void { this.scene.apply(kind, data); if (kind === "text") { this.history.push(`${data.author}: ${data.text}`); this.history = this.history.slice(-500); this.playbackElapsed = 0; } if (kind === "clear_history") this.history = []; if (kind === "wait") this.delay = Math.max(0, Number(data.duration)); if (kind === "ModifyBackground") { this.gallery.add(this.scene.state.background); this.persist(); } if (kind === "variable_changed") { if (data.name.startsWith("G_")) this.persist(); if (data.name === "SaveCall" && ["save", "load"].includes(data.value)) queueMicrotask(() => { if (!this.disposed) void this.slots(data.value === "load"); }); if (data.name === "Control_Progress" && Number(data.value) > 0) this.refreshAmbient(); } if (kind === "autosave") this.autosave = true; if (kind === "skip_control" && !data.enabled) this.mode = "normal"; if (kind === "Spawn" && data.Path === "PregMiniGame" && !this.mini) { this.mini = new TomieMinigame(this.vm.evaluator.random, Number(this.vm.variables.G_PregMiniGameHard ?? 1), Number(this.vm.variables.iWombState ?? 3)); this.miniControls.replaceChildren(); this.button(this.miniControls, "Начать", () => { this.mini?.start(); this.miniControls.replaceChildren(); }); } if (kind === "DestroySpawned" && data.Path === "PregMiniGame") this.mini = null; if (kind === "Spawn" && data.Path === "LewdInswitterComment") this.lewdComment = String(this.vm.evaluator.functions.GetLewdComment(0)); if (kind === "input") { this.inputName = data.variable; queueMicrotask(() => this.input(data.summary)); } if (kind === "ShowToastUI") this.status.textContent = this.plain(data.Text); if (kind === "ShowUI" && data.UINames?.includes("CGGalleryUI")) queueMicrotask(() => this.galleryDialog()); if (kind === "ShowUI" && data.UINames?.includes("Inventory")) queueMicrotask(() => this.inventory()); if (kind === "exit_to_title") queueMicrotask(this.onExit); if (kind === "error") this.status.textContent = data.message; if (["PlayBgm", "PlaySfx", "PlayVoice"].includes(kind)) { const channel = kind === "PlayBgm" ? "music" : kind === "PlayVoice" ? "voice" : "sound"; const track = this.scene.state[kind === "PlayBgm" ? "bgm" : kind === "PlayVoice" ? "voice" : "sfx"]; if (track) this.audio.play(channel, [track.file], { loop: track.loop, volume: track.volume, fadein: Number(data.FadeInDuration ?? 0) }); } if (kind === "StopBgm" || kind === "StopSfx") this.audio.stop(kind === "StopBgm" ? "music" : "sound", Number(data.FadeOutDuration ?? 0)); } private persist(): void { try { localStorage.setItem(this.profileKey, JSON.stringify({ variables: this.vm.global, gallery: [...this.gallery] })); } catch { /* browser storage unavailable */ } } private fail(error: unknown): void { this.status.textContent = (error as Error).message; this.mode = "normal"; } private render(): void { const state = this.scene.state; this.filters.replaceChildren(); this.backdrop.hidden = !state.backgroundFile; if (state.backgroundFile) this.backdrop.src = this.url(state.backgroundFile); this.backdrop.style.transform = `scale(${state.zoom * state.backgroundScale[0]}, ${state.zoom * state.backgroundScale[1]})`; this.backdrop.style.filter = this.tint(state.backgroundTint); this.actors.replaceChildren(); for (const [id, actor] of Object.entries(state.actors)) { if (!actor.visible) continue; const image = element("img", this.actors); image.alt = id; image.src = this.url(actor.file); const width = actor.size[0] * Math.abs(actor.scale[0]) / 1920 * 100, height = actor.size[1] * Math.abs(actor.scale[1]) / 1080 * 100; image.style.cssText = `position:absolute;width:${width}%;height:${height}%;left:${actor.position[0] - width / 2}%;bottom:${actor.position[1]}%;z-index:${-(actor.position[2] ?? 0)};transform:scale(${Math.sign(actor.scale[0])},${Math.sign(actor.scale[1])})`; image.style.filter = this.tint(actor.tint); } this.video.hidden = !state.video; if (state.video) { const url = this.url(state.video.file); if (this.video.getAttribute("src") !== url) this.video.src = url; const [, , size = "half", placement = "center"] = state.video.spec.split("@"); const width = ({ full: 100, half: 56.25, quater: 28.125, hafull: 84.375 } as Record)[size] ?? 56.25; this.video.style.width = width + "%"; this.video.style.left = (placement === "left" ? 0 : placement === "right" ? 100 - width : (100 - width) / 2) + "%"; if (state.video.speed > 0) this.video.playbackRate = Math.min(16, state.video.speed); } this.dialogue.hidden = !state.printerVisible || this.vm.onBoard; this.author.textContent = this.dictionary("CharacterNames", state.author); this.rich(this.text, state.text); this.choices.replaceChildren(); this.board.replaceChildren(); if (this.vm.onBoard) this.renderBoard(); else if (["choice", "timed_choice"].includes(this.vm.waiting)) { this.vm.choices.forEach((choice, index) => this.button(this.choices, this.plain(choice.ChoiceSummary), () => { this.vm.choose(index); this.render(); }, !this.vm.choiceSupported(choice))); } this.miniCanvas.hidden = !this.mini; this.miniControls.hidden = !this.mini; this.drawMini(); this.renderEffects(); } private refreshAmbient(): void { const variables = this.vm.variables; const line = (collection: string): string => { const entries = Object.values(this.content.resources[collection] ?? {}).flat(); if (!entries.length) return ""; const selected = entries[this.vm.evaluator.integer(0, entries.length - 1)]; let text = this.tr.line(selected.tid, selected.text); for (const [key, variants] of Object.entries(this.content.resources._synonyms ?? {})) { if (!variants.length) continue; const synonym = variants[this.vm.evaluator.integer(0, variants.length - 1)]; text = text.replaceAll(key, this.tr.line(synonym.tid, synonym.text)); } return text.replaceAll("{mName}", String(variables.mName ?? "man")); }; const suffix = Number(variables.Control_Progress) === 2 ? "_finish" : ""; const male = line("male_" + variables.Control_KeyMale + suffix), tomie = line("tomie_" + variables.Control_KeyTomie + suffix); this.ambient = (male ? `${variables.mName}: ${male}` : "") + (tomie ? `\nTomie: ${tomie}` : ""); } private renderEffects(): void { this.effects.replaceChildren(); for (const [name, params] of Object.entries(this.scene.state.effects)) { if (name === "PregMiniGame") continue; const node = element("div", this.effects, "tomie-effect"); if (name.startsWith("Shake")) { node.remove(); const target = name === "ShakePrinter" ? this.dialogue : name === "ShakeCamera" ? this.stage : this.actors; target.classList.remove("tomie-shake"); void target.offsetWidth; target.classList.add("tomie-shake"); } else if (["RedFlash", "WhiteFlash", "PinkFlash", "Gaze"].includes(name)) node.style.background = ({ RedFlash: "#ff140d73", WhiteFlash: "#ffffffb3", PinkFlash: "#ff59b34d", Gaze: "#0a08124d" } as Record)[name]; else if (name.startsWith("Hands")) { const path = this.content.media.textures["hand" + name.slice(5)]; if (!path) throw new Error("Не найден эффект " + name); const image = element("img", node); image.src = this.url(path); image.alt = ""; image.style.cssText = "width:100%;height:100%"; } else if (["EffectSuccess", "EffectFailed", "EffectHeart"].includes(name)) { node.classList.add("tomie-symbol"); node.textContent = ({ EffectSuccess: "✓", EffectFailed: "×", EffectHeart: "♥" } as Record)[name]; } else if (name === "DigitalGlitch") node.classList.add("tomie-glitch"); else if (name === "Rain") for (let index = 0; index < 140; index++) { const drop = element("i", node, "tomie-drop"); drop.style.left = (index * 137 % 1920) / 19.2 + "%"; drop.style.animationDelay = -(index * 83 % 1080) / 950 + "s"; } else if (name === "CamShowUI") { node.classList.add("tomie-caption"); const values = this.vm.variables; node.textContent = `Время: ${values.L_CamTime ?? 0} · Посетители: ${values.L_CamVisit ?? 0}\nДоход: $${values.L_CamCashToday ?? 0} · Поклонники: ${values.L_CamFan ?? 0}`; } else if (name === "LewdInswitterComment") { node.classList.add("tomie-caption"); this.rich(node, this.lewdComment); } else if (name === "SkillUp") { node.classList.add("tomie-caption"); this.rich(node, `${this.vm.variables.UpSkillTitle ?? params[0] ?? "Skill"}\n${this.vm.variables.UpSkillExplain ?? ""}`); } else throw new Error("Не поддержан эффект Tomie: " + name); } if (Number(this.vm.variables.Control_Progress ?? 0) > 0 && this.ambient) this.rich(element("div", this.effects, "tomie-ambient"), this.ambient); } private tint(value: string): string { if (["white", "#ffffff", "#fff"].includes(value.toLowerCase())) return "none"; const color = value.match(/^#([\da-f]{6})([\da-f]{2})?$/i); if (!color) throw new Error("Не поддерживается цвет Tomie: " + value); const [r, g, b] = [0, 2, 4].map((offset) => parseInt(color[1].slice(offset, offset + 2), 16) / 255); const alpha = color[2] ? parseInt(color[2], 16) / 255 : 1; const id = `${this.filterId}-${this.filters.childElementCount}`; const filter = document.createElementNS(this.filters.namespaceURI, "filter"), matrix = document.createElementNS(this.filters.namespaceURI, "feColorMatrix"); filter.setAttribute("id", id); filter.setAttribute("color-interpolation-filters", "sRGB"); matrix.setAttribute("type", "matrix"); matrix.setAttribute("values", `${r} 0 0 0 0 0 ${g} 0 0 0 0 0 ${b} 0 0 0 0 0 ${alpha} 0`); filter.append(matrix); this.filters.append(filter); return `url(#${id})`; } private renderBoard(): void { const values = this.vm.variables; const heading = element("div", this.board); heading.textContent = `День ${values.day} · $${values.money} · Воля ${values.iWillpower} · AP ${values.dActionPoint}`; const quest = element("div", this.board); this.rich(quest, String(values.iQuestMsg ?? "")); if (this.vm.waiting === "choice") { this.vm.choices.forEach((choice, index) => this.button(this.board, this.plain(choice.ChoiceSummary).replaceAll("@", " · "), () => { this.vm.choose(index); this.render(); }, !this.vm.choiceSupported(choice))); return; } const cells = element("div", this.board, "tomie-cells"); for (const cell of this.gameplay.cells()) { const label = element("span", cells); label.textContent = cell.title; label.title = this.plain(cell.detail); if (cell.index === Number(values.currentPosition)) label.classList.add("current"); } const dice = element("div", this.board); for (const [title, type] of [["1–6", 0], ["1–3 · −10 воли", 2], ["4–6 · −5 воли", 3], ["Нечётный · −5", 4], ["Чётный · −5", 5]] as [string, number][]) { this.button(dice, title, () => { const result = this.gameplay.roll(type); if (!result.ok) this.status.textContent = result.message ?? ""; this.render(); }); } const slider = element("input", this.board); slider.type = "range"; slider.min = "0"; slider.max = "2"; slider.step = "0.01"; slider.oninput = () => this.vm.variables._godot_dice_slider = Number(slider.value); this.button(this.board, "Кубик со шкалой", () => { this.gameplay.roll(1); this.render(); }); const jokers = element("div", this.board); jokers.append(`Joker: ${values.iJoker ?? 0} `); for (let value = 1; value <= 6; value++) this.button(jokers, String(value), () => { this.gameplay.roll(10 + value); this.render(); }, Number(values.iJoker ?? 0) < 1); } private drawMini(): void { const game = this.mini; if (!game) return; const context = this.miniCanvas.getContext("2d"); if (!context) return; context.fillStyle = "#0a101a"; context.fillRect(0, 0, 1920, 1080); context.fillStyle = "#d477ac"; context.beginPath(); context.arc(960, 540, 32, 0, Math.PI * 2); context.fill(); context.fillStyle = "#d9f2ff"; for (const particle of game.state.particles) { context.beginPath(); context.arc(particle.x, particle.y, 8, 0, Math.PI * 2); context.fill(); } context.font = "32px sans-serif"; context.textAlign = "center"; context.fillText(game.state.result === "waiting" ? "Клик или Space отталкивает частицы. Продержитесь 20 секунд." : game.state.result === "running" ? (20 - game.state.elapsed).toFixed(1) : game.state.result === "won" ? "Вы удержали защиту." : "Защита прорвана.", 960, 180); if (["won", "lost"].includes(game.state.result) && !this.miniControls.childElementCount) { this.button(this.miniControls, "Продолжить", () => { const won = game.state.result === "won"; this.mini = null; this.vm.trigger("Common2", won ? "PregNO" : "PregOK"); this.render(); }); } } private open(title: string): HTMLDivElement { this.modal.replaceChildren(); const header = element("h2", this.modal); header.textContent = title; this.button(this.modal, "Закрыть", () => this.modal.close()); const body = element("div", this.modal); if (!this.modal.open) this.modal.showModal(); return body; } private input(summary: string): void { if (this.disposed) return; const body = this.open(summary), field = element("input", body); field.maxLength = 500; this.button(body, "Продолжить", () => { this.modal.close(); this.vm.submitInput(this.inputName, field.value); this.render(); }); field.focus(); } private historyDialog(): void { const body = this.open("История"); for (const line of this.history) this.rich(element("p", body), line); } private inventory(): void { const body = this.open("Инвентарь"); for (const [id, count] of Object.entries(this.vm.evaluator.state.inventory)) { if (count <= 0) continue; this.button(body, `${this.dictionary("Inventory", id)} ×${count}`, () => { this.modal.close(); this.vm.runExternal("UseItem", id); this.render(); }, !this.vm.onBoard || !Object.hasOwn(this.content.scripts.UseItem?.labels ?? {}, id)); } } private statistics(): void { const body = this.open("Параметры и задания"); for (const key of ["day", "money", "iWillpower", "iPain", "iAttractive", "iPheromones", "iElegance", "iReputation", "iSubmissive", "iCorruption", "iQuestMsg", "contractString", "commentString"]) this.rich(element("p", body), `${key}: ${String(this.vm.variables[key] ?? "").replaceAll("^", "\n").replaceAll("@", " · ")}`); for (const message of String(this.vm.variables.messageString ?? "").split("^").filter(Boolean)) { const fields = message.split("@"); this.rich(element("p", body), fields.join(" · ")); this.button(body, "Открыть сообщение", () => { this.modal.close(); [this.vm.variables.mName, this.vm.variables.mMsg, this.vm.variables.mInfo] = fields; this.vm.runExternal("PGamesMessage"); this.render(); }, !this.vm.onBoard || fields.length < 4); } } private galleryDialog(): void { const body = this.open("Просмотренные иллюстрации"); const preview = element("img", body, "tomie-preview"); for (const name of this.gallery) if (this.content.media.backgrounds[name.toLowerCase()]) this.button(body, name, () => preview.src = this.url(this.content.media.backgrounds[name.toLowerCase()])); } private link(value: string): void { let url: URL; try { url = new URL(value); } catch { return; } if (!["https:", "http:"].includes(url.protocol)) return; const body = this.open("Открыть ссылку"); const link = element("a", body); link.href = url.href; link.target = "_blank"; link.rel = "noopener noreferrer"; link.textContent = url.href; } private snapshot(): TomieSave { return { format: "tomie/1", revision: this.manifest.revision, runtime: this.vm.snapshot(), scene: this.scene.snapshot(), mini: this.mini?.snapshot() ?? null, delay: this.delay, history: [...this.history], ambient: this.ambient, ambientElapsed: this.ambientElapsed, lewdComment: this.lewdComment }; } private async writeSave(slot: string): Promise { await this.saves.put(slot, `День ${this.vm.variables.day ?? 1} · ${this.vm.script}`, this.snapshot()); } private async slots(load: boolean): Promise { const body = this.open(load ? "Загрузить" : "Сохранить"), list = await this.saves.list(); if (!this.modal.open || this.disposed) return; for (const slot of ["auto", "quick", ...Array.from({ length: 8 }, (_, index) => String(index + 1))]) { const existing = list.find((entry) => entry.slot === slot); this.button(body, `${slot} · ${existing?.title ?? "Пусто"}`, () => { void (async () => { if (!load) await this.writeSave(slot); else { const saved = await this.saves.get(slot); if (!saved || saved.format !== "tomie/1" || (saved.revision && saved.revision !== this.manifest.revision)) throw new Error("Сохранение относится к другой редакции игры."); if (!this.vm.restore(saved.runtime)) throw new Error("Не удалось восстановить сценарий из сохранения."); this.scene.restore(saved.scene); this.delay = saved.delay; this.history = saved.history; this.mode = "normal"; this.ambient = saved.ambient ?? ""; this.ambientElapsed = saved.ambientElapsed ?? 0; this.lewdComment = saved.lewdComment ?? ""; this.mini = saved.mini ? new TomieMinigame(this.vm.evaluator.random) : null; if (saved.mini) this.mini!.restore(saved.mini); this.miniControls.replaceChildren(); this.audio.stopAll(); if (this.mini?.state.result === "waiting") this.button(this.miniControls, "Начать", () => { this.mini?.start(); this.miniControls.replaceChildren(); }); if (this.scene.state.bgm) this.audio.play("music", [this.scene.state.bgm.file], { loop: this.scene.state.bgm.loop, volume: this.scene.state.bgm.volume }); } this.modal.close(); this.render(); })().catch((error) => this.fail(error)); }, load && !existing); } } }