440 lines
28 KiB
TypeScript
440 lines
28 KiB
TypeScript
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);
|
||
}
|
||
}
|
||
}
|