Files
pgames-manager/frontend/src/player/tomie-player.ts
T

440 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, { script: string; label: string }>;
resources: Record<string, Record<string, { tid: string; text: string }[]>>;
}
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 = <K extends keyof HTMLElementTagNameMap>(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<string>();
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<TomieSave>, 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<string, any>): 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<string, number>)[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<string, string>)[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<string, string>)[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<void> {
await this.saves.put(slot, `День ${this.vm.variables.day ?? 1} · ${this.vm.script}`, this.snapshot());
}
private async slots(load: boolean): Promise<void> {
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);
}
}
}