feat: add native Tomie adapter and strict import compatibility checks

This commit is contained in:
mrleo1nid committed 2026-10-25 11:02:52 +03:00
1 parent c8d83b3fe7
commit 57a4edcbfd
45 files changed
+2985 -71

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { Engine } from "../player/engine";
import type { PlayerHandle } from "../player";
import { Icon } from "../ui/Icon";
export function Play() {
@@ -11,7 +11,7 @@ export function Play() {
const [error, setError] = useState("");
useEffect(() => {
let engine: Engine | null = null;
let engine: PlayerHandle | null = null;
let cancelled = false;
// the player is a separate chunk: the library UI does not pay for it
import("../player").then(({ launch }) =>
+7 -4
View File
@@ -11,6 +11,7 @@ interface Channel {
file: string | null;
loop: boolean;
queue: string[];
volume: number;
}
function fade(from: number, to: number, seconds: number, update: (gain: number) => void,
@@ -34,6 +35,7 @@ function fade(from: number, to: number, seconds: number, update: (gain: number)
interface Playback {
channel: string;
gain: number;
volume: number;
cancelFade: () => void;
}
@@ -55,20 +57,21 @@ export class AudioSystem {
private channel(name: string): Channel {
let c = this.channels.get(name);
if (!c) {
c = { el: null, file: null, loop: false, queue: [] };
c = { el: null, file: null, loop: false, queue: [], volume: 1 };
this.channels.set(name, c);
}
return c;
}
play(ch: string, files: string[], opts: { loop?: boolean | null; fadein?: number; fadeout?: number;
ifChanged?: boolean }) {
ifChanged?: boolean; volume?: number }) {
const c = this.channel(ch);
const loop = opts.loop ?? this.registered[ch]?.loop ?? ch === "music";
if (opts.ifChanged && c.file === files[0] && c.el && !c.el.paused) return;
this.stop(ch, opts.fadeout ?? (ch === "music" ? 0.5 : 0));
c.queue = files.slice(1);
c.loop = loop;
c.volume = Number.isFinite(opts.volume) ? Math.max(0, Math.min(1, opts.volume!)) : 1;
this.start(ch, c, files[0], opts.fadein ?? 0);
}
@@ -90,7 +93,7 @@ export class AudioSystem {
return;
}
const el = new Audio(src);
const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, cancelFade: () => {} };
const playback: Playback = { channel: ch, gain: fadein > 0 ? 0 : 1, volume: c.volume, cancelFade: () => {} };
this.playing.set(el, playback);
this.applyVolume(el, playback);
el.loop = c.loop && c.queue.length === 0;
@@ -114,7 +117,7 @@ export class AudioSystem {
}
private applyVolume(el: HTMLAudioElement, playback: Playback) {
el.volume = Math.max(0, Math.min(1, this.volumes[this.group(playback.channel)] * playback.gain));
el.volume = Math.max(0, Math.min(1, this.volumes[this.group(playback.channel)] * playback.gain * playback.volume));
}
private fadePlayback(el: HTMLAudioElement, playback: Playback, to: number, seconds: number,
+7 -3
View File
@@ -16,10 +16,10 @@ export interface SaveMeta {
updated_at: string;
}
export interface SaveApi {
export interface SaveApi<T = SaveData> {
list(): Promise<SaveMeta[]>;
get(slot: string): Promise<SaveData | null>;
put(slot: string, title: string, data: SaveData): Promise<void>;
get(slot: string): Promise<T | null>;
put(slot: string, title: string, data: T): Promise<void>;
remove(slot: string): Promise<void>;
}
@@ -799,6 +799,10 @@ export class Engine {
private characterFor(node: Node): { char: CharacterDef; name: string; mode: string } {
const who: string | undefined = node.who;
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" };
+38 -3
View File
@@ -15,7 +15,7 @@ function blob(manifest: Manifest, path: string) {
return `/api/versions/${manifest.versionId}/files/${f.hash}`;
}
function saveApi(versionId: number): SaveApi {
function saveApi<T = SaveData>(versionId: number): SaveApi<T> {
const base = `/api/versions/${versionId}/saves`;
return {
list: () => getJSON(base),
@@ -23,7 +23,7 @@ function saveApi(versionId: number): SaveApi {
const r = await fetch(`${base}/${slot}`);
if (r.status === 404) return null;
if (!r.ok) throw new Error(`${r.status}`);
return (await r.json()).data as SaveData;
return (await r.json()).data as T;
},
async put(slot, title, data) {
const r = await fetch(`${base}/${slot}`, {
@@ -39,8 +39,10 @@ function saveApi(versionId: number): SaveApi {
};
}
export interface PlayerHandle { destroy(): void }
export async function launch(root: HTMLElement, versionId: number, onExit: () => void,
onProgress: (text: string) => void): Promise<Engine> {
onProgress: (text: string) => void): Promise<PlayerHandle> {
onProgress("Загрузка манифеста");
const manifest = await getJSON<Manifest>(`/api/versions/${versionId}/manifest`);
const game = await getJSON<GameData>(blob(manifest, "game.json"));
@@ -53,6 +55,39 @@ export async function launch(root: HTMLElement, versionId: number, onExit: () =>
Promise.all(game.scripts.map((s) => getJSON<ScriptModule>(blob(manifest, s)))),
loadTranslator(game, manifest, lang),
]);
if (game.runtime === "tomie/1") {
if (!game.tomie) throw new Error("Не найдены исходные настройки Tomie");
const { TomiePlayer } = await import("./tomie-player");
const scripts: import("./nani-runtime").NaniPackage["scripts"] = Object.create(null);
const resources: NonNullable<GameData["naniResources"]> = Object.create(null);
for (const module of modules) {
if (!module.nani) {
for (const node of module.labels.flatMap((label) => label.body)) {
const record = node.resource;
if (!record || typeof node.what !== "string" || typeof record.collection !== "string" || typeof record.key !== "string") continue;
const collection = resources[record.collection] ??= Object.create(null);
(collection[record.key] ??= []).push({ ...record, tid: node.tid, text: node.what });
}
continue;
}
const nodes = module.labels.flatMap((label) => label.body);
const commands = nodes.map((node) => {
const command = structuredClone(node.command);
if (node.textField && typeof node.what === "string") command.args[node.textField] = { template: node.what };
command.tid = node.tid ?? command.tid; return command;
});
const labels = module.nani.label_ids ? Object.fromEntries(Object.entries(module.nani.label_ids).map(([label, id]) => {
const index = id ? nodes.findIndex((node) => node.id === id) : nodes.length;
if (index < 0) throw new Error("Не найдена команда метки Naninovel: " + label);
return [label, index];
})) : module.nani.labels;
scripts[module.nani.name] = { commands, labels, label_depths: module.nani.label_depths };
}
if (url.get("at")) throw new Error("Предпросмотр Tomie с отдельной строки ещё не поддерживается. Откройте игру с начала.");
const player = new TomiePlayer(root, { ...game.tomie, scripts, resources }, manifest,
saveApi<import("./tomie-player").TomieSave>(versionId), tr, onExit);
try { player.start(); return player; } catch (error) { player.destroy(); throw error; }
}
const engine = new Engine(root, game, manifest, modules, saveApi(versionId),
`pgvn:persistent:${versionId}`, onExit, tr,
(code) => loadTranslator(game, manifest, code));
+119
View File
@@ -0,0 +1,119 @@
// Runtime parser for dynamically composed expressions. This accepts the same
// bounded language as the import parser; it never evaluates JavaScript source.
import type { NaniAst } from "./naninovel";
const precedence: Record<string, number> = { or: 2, "||": 2, and: 3, "&&": 3, "|": 4, "^": 5,
"&": 6, "==": 7, "=": 7, "!=": 7, "<>": 7, "<": 8, ">": 8, "<=": 8, ">=": 8,
"<<": 9, ">>": 9, "+": 10, "-": 10, "*": 11, "/": 11, "%": 11 };
export function parseNani(source: string, functions: Set<string>): NaniAst {
if (source.length > 30000) throw new Error("Naninovel: слишком длинное выражение");
source = source.trim().replace(/;$/, "").trim();
const tokens: { value: any; kind: string }[] = [];
for (let offset = 0; offset < source.length;) {
if (/\s/.test(source[offset])) { offset++; continue; }
const wrapped = source[offset] === "\\" && ["\"", "'"].includes(source[offset + 1]);
if (["\"", "'"].includes(source[offset]) || wrapped) {
const quote = source[offset + (wrapped ? 1 : 0)];
offset += wrapped ? 2 : 1;
let value = "", closed = false;
while (offset < source.length) {
let char = source[offset];
if (wrapped && char === "\\" && source[offset + 1] === quote) { offset += 2; closed = true; break; }
if (!wrapped && char === quote) { offset++; closed = true; break; }
if (char === "\\" && offset + 1 < source.length) {
char = source[++offset];
char = ({ n: "\n", r: "\r", t: "\t" } as Record<string, string>)[char] ?? char;
}
value += char; offset++;
}
if (!closed) throw new Error("Naninovel: не закрыта строка выражения");
tokens.push({ kind: "string", value }); continue;
}
const rest = source.slice(offset);
const numeric = rest.match(/^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/);
const name = rest.match(/^[A-Za-z_]\w*/);
const operator = rest.match(/^(?:&&|\|\||==|!=|<>|<=|>=|<<|>>|[()+\-*/%<>=!~?:,&|^])/);
const found = numeric ?? name ?? operator;
if (!found) throw new Error(`Naninovel: неизвестный токен ${rest.slice(0, 40)}`);
tokens.push({ kind: numeric ? "number" : name ? "name" : "operator", value: numeric ? Number(found[0]) : found[0] });
offset += found[0].length;
}
let cursor = 0, depth = 0;
const peek = () => tokens[cursor]?.kind === "string" ? "\u0000string" : tokens[cursor]?.value ?? "";
const take = (expected?: string) => {
if (cursor >= tokens.length || (expected !== undefined && peek() !== expected)) throw new Error(`Naninovel: ожидалось ${expected ?? "выражение"}`);
return tokens[cursor++];
};
const expression = (minimum = 1): NaniAst => {
if (++depth > 64) throw new Error("Naninovel: слишком глубокое выражение");
const token = take(), value = token.value;
let left: NaniAst;
if (token.kind !== "string" && ["-", "+", "!", "not", "~"].includes(value)) left = ["unary", value, expression(12)];
else if (token.kind === "operator" && value === "(") { left = expression(); take(")"); }
else if (["number", "string"].includes(token.kind)) {
if (token.kind === "number" && !Number.isFinite(value)) throw new Error("Naninovel: неконечное число");
left = ["const", value];
} else if (token.kind === "name") {
if (["true", "false", "null"].includes(value.toLowerCase())) left = ["const", { true: true, false: false, null: null }[value.toLowerCase() as "true"]];
else if (peek() === "(") {
if (!functions.has(value) && value.toLowerCase() !== "if") throw new Error(`Naninovel: функция ${value} не поддерживается`);
take("(");
const args: NaniAst[] = [];
if (peek() !== ")") while (true) { args.push(expression()); if (peek() !== ",") break; take(","); }
take(")");
if (value.toLowerCase() === "if") {
if (args.length !== 3) throw new Error("Naninovel: If требует три аргумента");
left = ["if", ...args];
} else left = ["call", value, args];
} else left = ["name", value];
} else throw new Error(`Naninovel: некорректный токен ${value}`);
while (Object.hasOwn(precedence, peek()) && precedence[peek()] >= minimum) {
const op = take().value;
left = ["bin", op, left, expression(precedence[op] + 1)];
}
if (minimum <= 1 && peek() === "?") { take("?"); const yes = expression(); take(":"); left = ["if", left, yes, expression()]; }
depth--; return left;
};
const ast = expression();
if (cursor !== tokens.length) throw new Error("Naninovel: лишние токены выражения");
return ast;
}
export interface NaniCommand { op: string; args: Record<string, any>; line?: number; tid?: string }
export function inlineCommands(source: string | string[], line = -1): NaniCommand[] {
let lines: string[];
if (Array.isArray(source)) lines = source;
else {
lines = []; let start = 0, depth = 0, quote = "";
for (let index = 0; index < source.length; index++) {
const char = source[index];
if (quote) { if (char === "\\") index++; else if (char === quote) quote = ""; }
else if (["\"", "'"].includes(char)) quote = char;
else if (char === "(" || char === "{") depth++;
else if (char === ")" || char === "}") depth--;
else if (char === "," && depth === 0) { lines.push(source.slice(start, index)); start = index + 1; }
}
if (depth !== 0 || quote) throw new Error("Naninovel: незакрытая команда выбора");
lines.push(source.slice(start));
}
return lines.filter((line) => line.trim()).map((source) => {
source = source.trim();
if (!source.startsWith("@")) {
const speaker = source.match(/^([^:]{1,24}): (.*)$/s);
return { op: "PrintText", line, args: { Text: { template: speaker ? speaker[2] : source }, AuthorId: speaker?.[1] ?? "" } };
}
const [, name, body = ""] = source.match(/^(@\S+)(?:\s+(.*))?$/s)!;
const template = { template: body };
if (name === "@set") return { op: "SetCustomVariable", args: { Expression: template }, line };
if (["@goto", "@gosub"].includes(name)) return { op: name === "@goto" ? "Goto" : "Gosub", args: { Path: template }, line };
if (name === "@return") return { op: "Return", args: {}, line };
if (name === "@hideChars") return { op: "HideAllCharacters", args: { Duration: 0 }, line };
if (["@addItem", "@additem", "@removeItem"].includes(name)) return { op: name === "@removeItem" ? "RemoveItem" : "AddItem",
args: { ItemId: { template: body.split(/\s/, 1)[0] }, Amount: { template: body.match(/amount:([^ ]+)/)?.[1] ?? "1" } }, line };
if (name === "@voice") return { op: "PlayVoice", args: { VoicePath: { template: body.split(/\s/, 1)[0] } }, line };
if (name === "@hide") return { op: "HideActors", args: { ActorIds: body.split(",").filter(Boolean) }, line };
throw new Error(`Naninovel: неизвестная команда выбора ${name}`);
});
}
+330
View File
@@ -0,0 +1,330 @@
import { evaluateNani, naniTruth, type NaniAst } from "./naninovel";
import { inlineCommands, parseNani, type NaniCommand } from "./nani-parser";
import { TomieFunctions, type TomieFunctionHost } from "./tomie-functions";
export interface NaniScript {
commands: NaniCommand[];
labels: Record<string, number>;
label_depths?: Record<string, number>;
}
export interface NaniPackage {
scripts: Record<string, NaniScript>;
defaults: Record<string, any>;
start: { script: string; label: string };
initial_inventory?: Record<string, number>;
function_data: Record<string, string[]>;
}
interface Branch { parent: boolean; taken: boolean; active: boolean }
interface Frame { script: string; cursor: number; branches: Branch[]; pending: NaniCommand[] }
interface Choice extends Record<string, any> { tid?: string }
export interface NaniState {
version: 1;
script: string;
cursor: number;
variables: Record<string, any>;
branches: Branch[];
stack: Frame[];
choices: Choice[];
waiting: string;
pending: NaniCommand[];
last: NaniCommand;
timedChoice: number;
}
const copy = <T>(value: T): T => structuredClone(value);
export class NaniRuntime {
variables: Record<string, any> = {};
global: Record<string, any> = {};
script = "";
cursor = 0;
branches: Branch[] = [];
stack: Frame[] = [];
choices: Choice[] = [];
waiting = "";
pending: NaniCommand[] = [];
last: NaniCommand = { op: "", args: {} };
timedChoice = 0;
fastWaits = false;
readonly evaluator: TomieFunctions;
private ast = new Map<string, NaniAst>();
private functions: Set<string>;
constructor(public readonly content: NaniPackage,
private emit: (kind: string, data: Record<string, any>) => void,
private translate: (tid: string | undefined, text: string) => string = (_tid, text) => text,
host: Partial<Pick<TomieFunctionHost, "hasVideo" | "link" | "now">> = {}) {
this.evaluator = new TomieFunctions({ store: () => this.variables, tables: content.function_data,
hasLabel: (label) => Object.hasOwn(this.current().labels, label),
hasVideo: host.hasVideo ?? (() => false), link: host.link ?? (() => {}), now: host.now });
this.functions = new Set(Object.keys(this.evaluator.functions));
}
current(): NaniScript {
const script = this.content.scripts[this.script];
if (!script) throw new Error(`Naninovel: сценарий не найден ${this.script}`);
return script;
}
get onBoard(): boolean {
return this.waiting === "board" ||
(this.waiting === "choice" && this.script === "AppScreen" && this.last.op === "Stop" && this.last.line === 97);
}
evaluate(source: string): any {
let ast = this.ast.get(source);
if (!ast) { ast = parseNani(source, this.functions); this.ast.set(source, ast); }
return evaluateNani(ast, (name) => Object.hasOwn(this.variables, name) ? this.variables[name] : undefined,
this.evaluator.random, this.evaluator.functions);
}
interpolate(text: string): string {
// Match the port's right-to-left evaluation order, including RNG calls.
const matches = [...text.matchAll(/\{([^{}]+)\}/g)];
for (const match of matches.reverse()) {
const value = this.evaluate(match[1]);
text = text.slice(0, match.index) + String(value) + text.slice(match.index! + match[0].length);
}
return text;
}
resolve(value: any): any {
return value && typeof value === "object" && Object.hasOwn(value, "template") ? this.interpolate(value.template) : value;
}
text(value: any, tid?: string): string {
const template = value && typeof value === "object" && Object.hasOwn(value, "template") ? value.template : String(value ?? "");
return this.interpolate(this.translate(tid, template));
}
start(seed?: number): void {
this.variables = { ...copy(this.content.defaults), ...copy(this.global) };
this.branches = []; this.stack = []; this.pending = []; this.choices = []; this.waiting = "";
this.evaluator.state.inventory = copy(this.content.initial_inventory ?? {});
if (seed !== undefined) this.evaluator.state.rng = (seed >>> 0) || 1;
this.jump(this.content.start.script, this.content.start.label);
this.advance();
}
path(value: any): { script: string; label: string } {
value = this.resolve(value);
if (value && typeof value === "object") return { script: value.name ?? this.script, label: value.value ?? "" };
const [script, ...label] = String(value).split(".");
return { script: script || this.script, label: label.join(".") };
}
jump(script: string, label = ""): void {
const target = this.content.scripts[script];
if (!target || (label && !Object.hasOwn(target.labels, label))) throw new Error(`Naninovel: не найдена метка ${script}.${label}`);
this.script = script; this.cursor = label ? target.labels[label] : 0;
this.branches = [];
}
private branch(op: string, args: Record<string, any>): boolean {
// Recovered from BeginIf.HandleConditionalBlock in the pinned original DLL:
// branch commands seek forward in the playlist; EndIf itself is a no-op.
// A stack would mishandle gotos into/out of conditional blocks.
if (op === "EndIf") return true;
if (!["BeginIf", "Else", "ElseIf"].includes(op)) return false;
const skip = op !== "BeginIf";
if (!skip && naniTruth(this.evaluate(String(this.resolve(args.Expression))))) return true;
let depth = 0;
const commands = this.current().commands;
for (let index = this.cursor; index < commands.length; index++) {
const command = commands[index];
if (command.op === "BeginIf") { depth++; continue; }
if (depth && command.op === "EndIf") { depth--; continue; }
if (depth) continue;
if (command.op === "EndIf" || (!skip && (command.op === "Else" ||
(command.op === "ElseIf" && naniTruth(this.evaluate(String(this.resolve(command.args.Expression)))))))) {
this.cursor = index + 1; return true;
}
}
throw new Error("Naninovel: условный блок не имеет завершающей команды EndIf");
}
advance(): void {
if (["choice", "board", "complete", "error", "input", "stopped"].includes(this.waiting)) return;
this.waiting = "";
try {
let budget = 10000;
while (!this.waiting && budget-- > 0) {
const command = this.pending.length ? this.pending.shift() : this.current().commands[this.cursor++];
if (!command) throw new Error("Сценарий закончился без перехода");
this.last = command;
if (Object.hasOwn(command.args, "ConditionalExpression") &&
!naniTruth(this.evaluate(String(this.resolve(command.args.ConditionalExpression))))) continue;
if (this.branch(command.op, command.args)) continue;
this.execute(command);
}
if (!this.waiting) throw new Error("Превышен предел команд между репликами");
} catch (error) {
this.waiting = "error";
this.emit("error", { message: `${this.script}:${this.last.line ?? "?"} — ${(error as Error).message}` });
}
}
private pause(kind: string): void { this.waiting = kind; this.emit("paused", { kind }); }
private execute(command: NaniCommand): void {
const { op, args } = command;
switch (op) {
case "PrintText": case "AppendText":
this.emit("text", { text: this.text(args.Text ?? "", command.tid), author: String(this.resolve(args.AuthorId ?? "")),
reset: Boolean(args.ResetPrinter ?? op !== "AppendText"), tid: command.tid });
if (args.WaitForInput ?? true) this.pause("text");
return;
case "AppendLineBreak": this.emit("line_break", { count: Number(args.Count ?? 1) }); return;
case "WaitForInput": case "SkipInput": this.pause("text"); return;
case "SetCustomVariable": {
const expression = String(this.resolve(args.Expression));
const match = expression.trim().match(/^([A-Za-z_]\w*)\s*([+\-*/%&|^]?)=(?!=)\s*(.*)$/s);
if (!match || ["__proto__", "constructor", "prototype"].includes(match[1]) || match[1].startsWith("__pgames_")) throw new Error("Неподдерживаемое присваивание: " + expression);
const value = this.evaluate(match[3]);
this.variables[match[1]] = match[2] ? evaluateNani(["bin", match[2], ["const", this.variables[match[1]] ?? 0], ["const", value]], () => undefined) : value;
this.changed(match[1], this.variables[match[1]]); return;
}
case "AddChoice": this.choices.push({ ...copy(args), tid: command.tid, ChoiceSummary: this.text(args.ChoiceSummary ?? "", command.tid) }); return;
case "Stop":
if (this.choices.length) { this.pause("choice"); this.emit("choices", { items: copy(this.choices) }); }
else if (["AppScreen", "AB_Slum"].includes(this.script)) { this.pause("board"); this.emit("board", {}); }
else { this.pause("stopped"); this.emit("stopped", {}); }
return;
case "Goto": case "Gosub": {
const target = this.path(args.Path);
if (op === "Gosub") this.stack.push({ script: this.script, cursor: this.cursor, branches: copy(this.branches), pending: copy(this.pending) });
this.pending = []; this.jump(target.script, target.label); return;
}
case "Return": {
const frame = this.stack.pop();
if (!frame) throw new Error("Return без Gosub");
this.script = frame.script; this.cursor = frame.cursor; this.branches = frame.branches; this.pending = frame.pending; return;
}
case "Wait": {
const duration = Number(String(this.resolve(args.WaitMode ?? "0")).replace(/^i/, ""));
if (!this.choices.length && (duration <= 0 || this.fastWaits)) return;
if (this.choices.length && duration > 0) {
this.timedChoice = duration; this.pause("timed_choice"); this.emit("choices", { items: copy(this.choices) });
} else this.pause("timer");
this.emit("wait", { duration }); return;
}
case "AddItem": case "RemoveItem": {
const item = String(this.resolve(args.ItemId)), amount = Math.max(0, Math.trunc(Number(this.resolve(args.Amount ?? 1))));
const inventory = this.evaluator.state.inventory, count = inventory[item] ?? 0;
if (["__proto__", "constructor", "prototype"].includes(item)) throw new Error("Некорректный ID предмета");
if (op === "RemoveItem" && amount > count) throw new Error("Недостаточно предметов: " + item);
inventory[item] = op === "AddItem" ? count + amount : count - amount;
this.emit("inventory_changed", { item, count: inventory[item] }); return;
}
case "ClearChoiceHandler": {
const handler = this.resolve(args.HandlerId ?? "*");
this.choices = this.choices.filter((choice) => handler !== "*" && choice.HandlerId !== handler);
this.emit("clear_choices", {}); return;
}
case "InputCustomVariable": this.pause("input"); this.emit("input", {
variable: String(this.resolve(args.VariableName ?? "input")), summary: this.text(args.Summary ?? "", command.tid) }); return;
case "ExitToTitle": this.waiting = "complete"; this.emit("exit_to_title", {}); return;
case "ShowToastUI": this.emit(op, { ...args, Text: this.text(args.Text ?? "", command.tid) }); return;
case "ClearBacklog": this.emit("clear_history", {}); return;
case "AutoSave": this.emit("autosave", {}); return;
case "ProcessInput": this.emit("process_input", { enabled: Boolean(this.resolve(args.InputEnabled ?? true)) }); return;
case "Skip": this.emit("skip_control", { enabled: Boolean(this.resolve(args.Enable ?? false)) }); return;
case "HideUI": case "ShowUI": case "Spawn": case "DestroySpawned": case "ModifyCamera":
case "PlayVoice": case "ShowPrinter": case "ModifyBackground": case "ModifyCharacter":
case "HideAllCharacters": case "HideActors": case "ModifyTextPrinter": case "HidePrinter":
case "PlaySfx": case "PlayBgm": case "StopSfx": case "StopBgm": {
const resolved = Object.fromEntries(Object.entries(args).map(([key, value]) => [key, this.resolve(value)]));
this.emit(op, resolved);
const duration = Number(resolved.Duration ?? 0);
if (["ModifyBackground", "ModifyCharacter", "HideAllCharacters", "HideActors", "ModifyTextPrinter", "HidePrinter", "PlaySfx", "PlayBgm", "StopSfx", "StopBgm"].includes(op) &&
(resolved.Wait ?? true) && duration > 0 && !this.fastWaits && !this.waiting) {
this.pause("timer"); this.emit("wait", { duration });
}
return;
}
default: throw new Error("Не поддержана команда Naninovel: " + op);
}
}
choiceSupported(choice: Choice): boolean {
try {
for (const command of inlineCommands(this.resolve(choice.OnSelected ?? []))) {
if (command.op === "RemoveItem" && Number(this.resolve(command.args.Amount ?? 1)) >
(this.evaluator.state.inventory[String(this.resolve(command.args.ItemId))] ?? 0)) return false;
}
if (choice.GotoPath) {
const target = this.path(choice.GotoPath), script = this.content.scripts[target.script];
if (!script || (target.label && !Object.hasOwn(script.labels, target.label))) return false;
}
return true;
} catch { return false; }
}
choose(index: number): void {
if (!["choice", "timed_choice"].includes(this.waiting) || !Number.isInteger(index) || !this.choices[index]) return;
const selected = this.choices[index];
if (!this.choiceSupported(selected)) return;
this.choices = []; this.waiting = "";
this.pending = inlineCommands(this.resolve(selected.OnSelected ?? []), this.last.line);
if (selected.GotoPath) this.pending.push({ op: "Goto", args: { Path: selected.GotoPath }, line: this.last.line });
this.advance();
}
runExternal(script: string, label = ""): void {
this.stack = []; this.trigger(script, label);
}
trigger(script: string, label = ""): void {
this.pending = []; this.choices = []; this.waiting = "";
this.jump(script, label); this.advance();
}
changed(key: string, value: any): void {
if (key.startsWith("G_")) this.global[key] = value;
if (["newContract", "newMessage", "newComment"].includes(key) && String(value)) {
const destination = { newContract: "contractString", newMessage: "messageString", newComment: "commentString" }[key as "newContract"];
const incoming = String(value).split("@"), identity = key === "newMessage" ? 2 : 1;
if (incoming.length <= identity) { this.variables[key] = ""; return; }
let exists = false;
const updated = String(this.variables[destination] ?? "").split("^").filter(Boolean).filter((entry) => {
const fields = entry.split("@");
if (fields.length <= identity) return false;
if (fields[0] !== incoming[0]) return true;
if (fields[identity] === incoming[identity]) { exists = true; return true; }
return ["newComment", "newMessage"].includes(key) && incoming[0] === "Anonymous";
});
if (!exists) updated.unshift(String(value));
this.variables[destination] = updated.join("^"); this.variables[key] = "";
}
if (key === "messageChanged" && String(value)) {
const [identity, ...rest] = String(value).split("#"), replacement = rest.join("#");
const entries = String(this.variables.messageString ?? "").split("^").filter((entry) => entry.split("@").length > 2 && entry.split("@")[2] !== identity);
if (rest.length && replacement !== "remove") entries.unshift(replacement);
this.variables.messageString = entries.join("^"); this.variables[key] = "";
}
this.emit("variable_changed", { name: key, value });
}
submitInput(name: string, value: string): void {
if (this.waiting !== "input" || ["__proto__", "constructor", "prototype"].includes(name) || name.startsWith("__pgames_")) return;
this.variables[name] = value; this.changed(name, value); this.waiting = ""; this.advance();
}
snapshot(): NaniState {
return copy({ version: 1, script: this.script, cursor: this.cursor, variables: this.variables,
branches: this.branches, stack: this.stack, choices: this.choices, waiting: this.waiting,
pending: this.pending, last: this.last, timedChoice: this.timedChoice });
}
restore(state: NaniState): boolean {
const validFrame = (frame: { script: string; cursor: number }) => this.content.scripts[frame.script] &&
Number.isInteger(frame.cursor) && frame.cursor >= 0 && frame.cursor <= this.content.scripts[frame.script].commands.length;
if (state?.version !== 1 || !validFrame(state) || !Array.isArray(state.stack) || !state.stack.every(validFrame) ||
!["text", "choice", "board", "complete", "timed_choice", "timer", "input", "stopped"].includes(state.waiting)) return false;
const saved = copy(state);
this.script = saved.script; this.cursor = saved.cursor; this.variables = { ...saved.variables, ...copy(this.global) };
this.branches = saved.branches; this.stack = saved.stack; this.choices = saved.choices;
this.waiting = saved.waiting; this.pending = saved.pending; this.last = saved.last; this.timedChoice = saved.timedChoice;
return true;
}
}
+21 -8
View File
@@ -12,7 +12,18 @@ function number(value: any): number {
return n;
}
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random): any {
function equal(a: any, b: any): boolean {
if (a === null || b === null) return a === null && b === null;
if ((typeof a === "string") !== (typeof b === "string")) {
const text = typeof a === "string" ? a : b;
return text.trim() !== "" && Number.isFinite(Number(text)) && Number(a) === Number(b);
}
if (["number", "boolean"].includes(typeof a) && ["number", "boolean"].includes(typeof b)) return Number(a) === Number(b);
return typeof a === typeof b && a === b;
}
export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random = Math.random,
functions: Record<string, (...args: any[]) => any> = {}): any {
const evaluate = (node: NaniAst): any => {
switch (node[0]) {
case "const": return node[1];
@@ -37,12 +48,12 @@ export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random
case "*": return number(a) * number(b);
case "/": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) / number(b);
case "%": if (number(b) === 0) throw new Error("Naninovel: деление на ноль"); return number(a) % number(b);
case "=": case "==": return String(a) === String(b);
case "!=": case "<>": return String(a) !== String(b);
case "<": return number(a) < number(b);
case ">": return number(a) > number(b);
case "<=": return number(a) <= number(b);
case ">=": return number(a) >= number(b);
case "=": case "==": return equal(a, b);
case "!=": case "<>": return !equal(a, b);
case "<": return typeof a === "string" && typeof b === "string" ? a < b : number(a) < number(b);
case ">": return typeof a === "string" && typeof b === "string" ? a > b : number(a) > number(b);
case "<=": return typeof a === "string" && typeof b === "string" ? a <= b : number(a) <= number(b);
case ">=": return typeof a === "string" && typeof b === "string" ? a >= b : number(a) >= number(b);
case "&": return number(a) & number(b);
case "|": return number(a) | number(b);
case "^": return number(a) ^ number(b);
@@ -52,7 +63,9 @@ export function evaluateNani(ast: NaniAst, lookup: (name: string) => any, random
}
}
case "call": {
const args = node[2].map((value: NaniAst) => number(evaluate(value)));
const values = node[2].map((value: NaniAst) => evaluate(value));
if (Object.hasOwn(functions, node[1])) return functions[node[1]](...values);
const args = values.map(number);
switch (node[1]) {
case "Random": case "random": case "Rand": {
const min = args.length > 1 ? args[0] : 0, max = args.length > 1 ? args[1] : args[0];
+147
View File
@@ -0,0 +1,147 @@
// Game-specific expression functions recovered in the Tomie Godot port.
// State belongs to the save store, including RNG and temporary random lists.
export interface TomieFunctionHost {
store(): Record<string, any>;
tables: Record<string, string[]>;
hasLabel(name: string): boolean;
hasVideo(name: string): boolean;
link(url: string): void;
now?(): Date;
}
interface State {
rng: number;
randomList: string[];
inventory: Record<string, number>;
sum: string;
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
const str = (value: any): string => value === null || value === undefined ? "<null>" : String(value);
const roundEven = (value: number) => {
const base = Math.floor(value);
return value - base === 0.5 ? (base % 2 === 0 ? base : base + 1) : Math.floor(value + 0.5);
};
export class TomieFunctions {
constructor(private host: TomieFunctionHost) {}
get state(): State {
const store = this.host.store();
if (!Object.hasOwn(store, "__pgames_tomie")) {
store.__pgames_tomie = { rng: (Date.now() >>> 0) || 1, randomList: [], inventory: {}, sum: "" };
}
return store.__pgames_tomie;
}
random = (): number => {
let x = this.state.rng | 0;
x ^= x << 13; x ^= x >>> 17; x ^= x << 5;
this.state.rng = x >>> 0;
return this.state.rng / 0x100000000;
};
integer(min: number, max: number): number {
return Math.trunc(min) + Math.floor(this.random() * (Math.trunc(max) - Math.trunc(min) + 1));
}
private pick(value: string, delimiter: string): string {
if (!value) return "";
const entries = value.split(delimiter);
return entries[this.integer(0, entries.length - 1)];
}
private element(value: any, index: number, separator: string): string {
const values = str(value).split(separator);
return values[index >= 0 && index < values.length ? index : 0];
}
private table(name: string): string[] {
const values = this.host.tables[name];
if (!values?.length) throw new Error(`Tomie: не загружена исходная таблица ${name}`);
return values;
}
private comment(name: string): string {
const values = this.table(name);
return values[this.integer(0, Math.max(0, values.length - 2))];
}
readonly functions: Record<string, (...args: any[]) => any> = {
WillpowerDamage: (current, corruption, amount, min, max) => clamp(current - amount * 0.7 - amount * 0.3 * ((100 - corruption) / 100), min, max),
RoundValue: (value) => roundEven(Number(value)),
MinMax: (value, min, max) => clamp(Number(value), min, max),
Random: (...args) => {
if (typeof args[0] === "string") return args[this.integer(0, args.length - 1)];
const [min, max] = args.map(Number);
return Number.isInteger(min) && Number.isInteger(max) ? this.integer(min, max) : min + this.random() * (max - min);
},
random: (...args) => this.functions.Random(...args),
Rand: (min, max = null) => max === null ? this.functions.GetRandValue(min) : this.functions.Random(min, max),
NewRandList: () => { this.state.randomList = []; return 0; },
AddRandList: (value, count = 1) => {
if (count > 100000) throw new Error("Tomie: слишком большой случайный список");
for (let i = 0; i < count; i++) this.state.randomList.push(str(value));
return 0;
},
MergeRandList: () => this.state.randomList.join("&"),
HasValue: (name) => Object.hasOwn(this.host.store(), name),
StringContain: (value, part) => str(value).includes(part),
StringReplace: (value, old, replacement) => str(value).split(old).join(replacement),
ItemExist: (name) => (this.state.inventory[name] ?? 0) > 0,
itemExist: (name) => this.functions.ItemExist(name),
ItemCount: (name) => this.state.inventory[name] ?? 0,
AddListString: (value, entry) => str(value) ? str(value) + "@" + str(entry) : str(entry),
AddListString2: (value, entry) => str(value).includes(str(entry)) ? str(value) : this.functions.AddListString(value, entry),
AddListStringWithRemoveDot: (value, entry) => str(value) ? this.functions.AddListString(value, str(entry).replaceAll(".", "")) : str(entry),
RemoveListString: (value, entry) => !str(entry) ? str(value) : str(value).replaceAll(str(entry) + "@", "")
.replaceAll("@" + str(entry), "").replaceAll(str(entry), "").replaceAll("@@", ""),
StringContains: (value, targets) => str(targets).split(",").some((part) => str(value).includes(part)),
StringReplaceSeed: (value) => str(value).replaceAll("-", "_"),
IntParse: (value) => Math.trunc(Number(value)) || 0,
GetListCount: (value) => str(value) ? str(value).split("@").length : 0,
GetArrayCount: (value) => this.functions.GetListCount(value),
GetFromArray: (value, index) => this.element(value, index, "@"),
GetFromRandPick2: (value, index) => this.element(value, index, "@"),
GetFromRandPick3: (value, index) => this.element(value, index, "*"),
StringSplitComma: (value, index) => this.element(value, index, ","),
RandPick: (value) => this.pick(str(value).replace(/^,/, ""), ","),
RandPick2: (value) => this.pick(str(value).replace(/^@/, ""), "@"),
RandSort: (value) => {
const values = str(value).split("@");
for (let i = values.length - 1; i > 0; i--) {
const j = this.integer(0, i); [values[i], values[j]] = [values[j], values[i]];
}
return values.join("@");
},
RandPicks: (value) => {
const values = this.functions.RandSort(value).split("@");
return values.slice(0, this.integer(1, Math.max(1, values.length - 1))).join(",");
},
GetRandValue: (max) => this.integer(0, Math.max(0, Math.trunc(max) - 1)),
GetRandList: () => this.state.randomList.length ? this.state.randomList[this.integer(0, this.state.randomList.length - 1)] : "null",
GetRandListViaIndex: (index) => this.state.randomList.length ? this.state.randomList[clamp(index, 0, this.state.randomList.length - 1)] : "null",
ExtractRandList: (value) => { this.state.randomList = str(value).split("&"); return ""; },
GetTargetList: () => this.state.randomList.length ? this.state.randomList[this.integer(0, Math.max(0, this.state.randomList.length - 2))] : "null",
Pow: Math.pow,
Round: (value, digits = 0) => roundEven(value * 10 ** digits) / 10 ** digits,
HasLabel: (name) => this.host.hasLabel(name),
GetName: (index) => {
const names = this.table("names");
return names[index >= 0 && index < names.length ? index : this.integer(0, Math.max(0, names.length - 2))] + "_BF";
},
GetRandName: () => this.comment("names"),
GetNormalComment: () => this.comment("normalComment"),
GetLewdComment: () => this.comment("lewdComment"),
GetVODFileCount: () => this.table("videos").length,
GetVODFileNameVidIndex: (index) => this.host.tables.videos?.[index] ?? "none",
GetVODFileNames: () => false,
GetMODFileNames: () => false,
GetModList: () => "",
HasVideo: (name) => this.host.hasVideo(str(name).toLowerCase()),
OpenURL: (url) => { this.host.link(str(url)); return ""; },
IsChristmas: () => { const date = this.host.now?.() ?? new Date(); return date.getMonth() === 11 && [20, 21, 23, 24, 25].includes(date.getDate()); },
ResetString: () => { this.state.sum = ""; return ""; },
SumString: (value) => { this.state.sum += str(value); return this.state.sum; },
};
}
+41
View File
@@ -0,0 +1,41 @@
import type { NaniRuntime } from "./nani-runtime";
export interface BoardCell { index: number; code: number; area: string; icon: string; title: string; detail: string }
export class TomieGameplay {
constructor(private runtime: NaniRuntime, private routes: Record<string, { script: string; label: string }>) {}
cells(): BoardCell[] {
return String(this.runtime.variables.savedBlock ?? "").split("&").filter(Boolean).flatMap((value) => {
const parts = value.split("@");
return parts.length < 6 ? [] : [{ index: Number(parts[0]), code: Number(parts[1]), area: parts[2], icon: parts[3], title: parts[4], detail: parts[5] }];
});
}
roll(type = 0): { ok: boolean; message?: string; roll?: number; destination?: number } {
const vm = this.runtime, values = vm.variables;
if (vm.waiting !== "board") return { ok: false, message: "Сначала завершите текущее действие." };
const cost = type === 2 ? 10 : [3, 4, 5].includes(type) ? 5 : 0;
if (Number(values.iWillpower ?? 0) < cost) return { ok: false, message: "Недостаточно воли для этого кубика." };
if (type > 10 && (type > 16 || Number(values.iJoker ?? 0) < 1)) return { ok: false, message: "Нужна одна карта Joker." };
const rng = vm.evaluator;
let number: number;
if (type === 0 || type === 1) {
number = rng.integer(1, 6);
if (type === 1 && rng.random() >= 0.6) number = Math.min(6, Math.max(1, Math.trunc(Number(values._godot_dice_slider ?? 0) * 3) + 1));
} else if (type === 2) number = rng.integer(1, 3);
else if (type === 3) number = rng.integer(4, 6);
else if (type === 4) number = rng.integer(1, 3) * 2 - 1;
else if (type === 5) number = rng.integer(1, 3) * 2;
else if (type > 10) number = type - 10;
else return { ok: false, message: "Этот тип кубика не подключён." };
const cells = this.cells(), destination = Math.min(Number(values.currentPosition ?? 0) + number, cells.length - 1);
const cell = cells[destination], target = cell && this.routes[String(cell.code)];
if (!cell || !target || !vm.content.scripts[target.script]) return { ok: false, message: "Неизвестный тип клетки. Позиция и ресурсы сохранены." };
values.mDiceResult = number; values.currentPosition = destination; values.thisBlockNumber = cell.code;
values.iWillpower = Number(values.iWillpower ?? 0) - cost;
if (type > 10) values.iJoker = Number(values.iJoker ?? 0) - 1;
values.isAppScreen = false;
vm.runExternal(target.script, target.label);
return { ok: true, roll: number, destination };
}
}
+71
View File
@@ -0,0 +1,71 @@
export interface MiniState {
elapsed: number;
spawnElapsed: number;
particles: { x: number; y: number; speed: number }[];
result: "waiting" | "running" | "won" | "lost";
difficulty: number;
wombState: number;
}
// The same 1920×1080 rules as the recovered port. The host owns timing and RNG,
// so modal dialogs/focus loss pause the game and saves retain exact progress.
export class TomieMinigame {
state: MiniState;
constructor(private random: () => number, difficulty = 1, wombState = 3) {
this.state = { elapsed: 0, spawnElapsed: 0, particles: [], result: "waiting", difficulty, wombState };
}
start(): void {
this.state.elapsed = 0; this.state.spawnElapsed = 0;
this.state.particles = []; this.state.result = "running";
}
pulse(): void {
if (this.state.result !== "running") return;
const force = this.state.difficulty === 1 ? 6 : this.state.difficulty === 2 ? 4 : 25;
for (const particle of this.state.particles) {
const distance = Math.hypot(particle.x - 960, particle.y - 540);
if (distance) {
particle.x += (particle.x - 960) / distance * force;
particle.y += (particle.y - 540) / distance * force;
}
particle.speed += 0.01;
}
}
update(delta: number, paused = false): void {
if (paused || this.state.result !== "running" || !Number.isFinite(delta) || delta <= 0) return;
// Fixed substeps make outcome independent of display refresh rate. The host
// must pass zero after returning from a background tab, not a wall-clock gap.
let remaining = Math.min(delta, 1);
while (remaining > 1e-9 && this.state.result === "running") {
const step = Math.min(remaining, 1 / 120);
remaining -= step;
this.step(step);
}
}
private step(delta: number): void {
const state = this.state;
state.elapsed += delta; state.spawnElapsed += delta;
if (state.elapsed >= 20) { state.result = "won"; state.particles = []; return; }
const interval = state.wombState <= 3 ? 0.45 : state.wombState === 4 ? 0.33 : 0.25;
if (state.spawnElapsed >= interval) {
state.spawnElapsed -= interval;
const angle = this.random() * Math.PI * 2;
let speed = 45 + this.random() * 30;
if (speed >= 70) { speed = 70 + this.random() * 15; if (speed >= 84) speed = 84 + this.random() * 11; }
state.particles.push({ x: 960 + Math.sin(angle) * 700, y: 540 + Math.cos(angle) * 700, speed });
}
for (const particle of state.particles) {
const distance = Math.hypot(particle.x - 960, particle.y - 540);
const movement = Math.min(distance, particle.speed * delta);
if (distance) { particle.x -= (particle.x - 960) / distance * movement; particle.y -= (particle.y - 540) / distance * movement; }
if (distance - movement < 32) { state.result = "lost"; state.particles = []; return; }
}
}
snapshot(): MiniState { return structuredClone(this.state); }
restore(state: MiniState): void { this.state = structuredClone(state); }
}
+439
View File
@@ -0,0 +1,439 @@
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);
}
}
}
+132
View File
@@ -0,0 +1,132 @@
// Serializable presentation state. Restoring a save never replays script side effects.
export interface TomieMedia {
backgrounds: Record<string, string>;
characters: Record<string, { file: string; size: [number, number] }>;
textures: Record<string, string>;
videos: Record<string, string>;
audio: Record<string, string>;
fonts: Record<string, string>;
}
interface Actor {
appearance: string; file: string; size: [number, number]; visible: boolean;
position: number[]; scale: number[]; tint: string; direction: string;
}
interface Track { path: string; file: string; volume: number; loop: boolean }
export interface TomieVisual {
background: string;
backgroundFile: string;
backgroundScale: number[];
backgroundTint: string;
actors: Record<string, Actor>;
text: string;
author: string;
tid?: string;
printer: string;
printerVisible: boolean;
zoom: number;
bgm: Track | null;
sfx: Track | null;
voice: Track | null;
video: { spec: string; file: string; speed: number; loop: boolean } | null;
effects: Record<string, any[]>;
ui: string[];
inputEnabled: boolean;
}
const named = (value: any): { name: string; value: string } => {
if (value && typeof value === "object") return { name: String(value.name ?? ""), value: String(value.value ?? "") };
const [name, ...rest] = String(value ?? "").split(".");
return { name, value: rest.join(".") };
};
const number = (value: any, fallback: number) => Number.isFinite(Number(value)) ? Number(value) : fallback;
const scale = (value: any, fallback: number[]) => value == null ? [...fallback] :
Array.isArray(value) ? [number(value[0], 1), number(value[1] ?? value[0], 1)] : [number(value, 1), number(value, 1)];
const safeKey = (key: string) => {
if (["__proto__", "constructor", "prototype"].includes(key)) throw new Error("Некорректный ID ресурса Tomie");
return key;
};
export class TomieScene {
state: TomieVisual = {
background: "black", backgroundFile: "", backgroundScale: [1, 1], backgroundTint: "white",
actors: {}, text: "", author: "", printer: "Wide", printerVisible: true, zoom: 1,
bgm: null, sfx: null, voice: null, video: null, effects: {}, ui: [], inputEnabled: true,
};
constructor(readonly media: TomieMedia) {}
private resource(collection: Record<string, string>, name: string): string {
const path = Object.hasOwn(collection, name.toLowerCase()) ? collection[name.toLowerCase()] : "";
if (!path) throw new Error("Tomie: не найден ресурс " + name);
return path;
}
apply(kind: string, data: Record<string, any>): boolean {
const state = this.state;
switch (kind) {
case "text":
state.text = data.reset ? data.text : state.text + data.text;
state.author = data.author; state.tid = data.tid; state.printerVisible = true; return true;
case "line_break": state.text += "\n".repeat(Math.min(100, Math.max(0, number(data.count, 1)))); return true;
case "HidePrinter": state.printerVisible = false; return true;
case "ShowPrinter": state.printerVisible = true; return true;
case "ModifyTextPrinter":
state.printer = named(data.IdAndAppearance).name || state.printer;
state.printerVisible = true; return true;
case "ModifyBackground": {
const name = named(data.AppearanceAndTransition).name;
state.backgroundFile = name.toLowerCase() === "black" ? "" : this.resource(this.media.backgrounds, name);
state.background = name;
state.backgroundScale = scale(data.Scale, [1, 1]); state.backgroundTint = String(data.TintColor ?? "white"); return true;
}
case "ModifyCharacter": {
const value = named(data.IdAndAppearance), id = safeKey(value.name);
const previous = state.actors[id];
if (data.Visible === false || data.Visible === 0) {
if (previous) previous.visible = false;
return true;
}
const appearance = value.value || previous?.appearance || "";
const entry = this.media.characters[(id + "/" + appearance).toLowerCase()];
if (!entry) throw new Error(`Tomie: не найден образ ${id}/${appearance}`);
state.actors[id] = { appearance, ...entry, visible: true,
position: data.ScenePosition ?? previous?.position ?? [50, 0],
scale: scale(data.Scale, previous?.scale ?? [1, 1]), tint: data.TintColor ?? previous?.tint ?? "white",
direction: data.LookDirection ?? previous?.direction ?? "Center" };
return true;
}
case "HideAllCharacters": Object.values(state.actors).forEach((actor) => actor.visible = false); return true;
case "HideActors":
for (const id of data.ActorIds ?? []) if (Object.hasOwn(state.actors, id)) state.actors[id].visible = false;
return true;
case "ModifyCamera": state.zoom = 1 + number(data.Zoom, 0); return true;
case "PlayBgm": case "PlaySfx": case "PlayVoice": {
const channel = kind === "PlayBgm" ? "bgm" : kind === "PlayVoice" ? "voice" : "sfx";
const path = String(data[kind === "PlayBgm" ? "BgmPath" : kind === "PlayVoice" ? "VoicePath" : "SfxPath"] ?? "");
state[channel] = path ? { path, file: this.resource(this.media.audio, path), volume: number(data.Volume, 1), loop: Boolean(data.Loop) } : null;
return true;
}
case "StopBgm": state.bgm = null; return true;
case "StopSfx": state.sfx = null; return true;
case "process_input": state.inputEnabled = Boolean(data.enabled); return true;
case "ShowUI": state.ui = [...new Set([...state.ui, ...(data.UINames ?? [])])]; return true;
case "HideUI": state.ui = state.ui.filter((name) => !(data.UINames ?? []).includes(name)); return true;
case "Spawn": state.effects[safeKey(String(data.Path))] = structuredClone(data.Params ?? []); return true;
case "DestroySpawned": delete state.effects[safeKey(String(data.Path))]; return true;
case "variable_changed":
if (["videoBack", "videoPlay"].includes(data.name)) {
const spec = String(data.value ?? "");
if (!spec || ["null", "none"].includes(spec)) state.video = null;
else {
const name = spec.split("@")[0];
state.video = { spec, file: this.resource(this.media.videos, name), speed: state.video?.speed ?? 1, loop: true };
}
} else if (data.name === "videoSpeed" && state.video) state.video.speed = Math.max(0, number(data.value, 1));
return true;
default: return false;
}
}
snapshot(): TomieVisual { return structuredClone(this.state); }
restore(state: TomieVisual): void { this.state = structuredClone(state); }
}
+37
View File
@@ -0,0 +1,37 @@
.tomie-root { position: relative; width: 100%; height: 100%; background: #090b11; color: #f6f2ec; font-family: system-ui, sans-serif; display: flex; flex-direction: column; }
.tomie-stage { position: relative; flex: 1; overflow: hidden; background: #000; }
.tomie-background,.tomie-actors,.tomie-effects,.tomie-mini { position: absolute; inset: 0; width: 100%; height: 100%; }
.tomie-background { object-fit: fill; }
.tomie-actors { pointer-events: none; }
.tomie-video { position: absolute; top: 0; height: 100%; object-fit: fill; }
.tomie-effects { pointer-events: none; }
.tomie-dialogue { position: absolute; left: 8%; right: 8%; bottom: 3%; background: #111621ed; border: 1px solid #8f7e69; border-radius: 12px; text-align: left; padding: 18px 28px; color: inherit; max-height: 42%; overflow: auto; }
.tomie-author { color: #f7bc7c; font-weight: 600; margin-bottom: 8px; }
.tomie-text { white-space: pre-wrap; }
.tomie-choices { position: absolute; right: 9%; top: 8%; max-height: 63%; overflow: auto; display: grid; gap: 8px; max-width: 75%; }
.tomie-board { position: absolute; right: 2%; top: 3%; max-height: 88%; max-width: 72%; overflow: auto; background: #121822ed; border-radius: 12px; padding: 16px; }
.tomie-board:empty { display: none; }
.tomie-root button { cursor: pointer; color: inherit; background: #293343; border: 1px solid #576579; border-radius: 7px; padding: 9px 13px; margin: 3px; white-space: pre-wrap; }
.tomie-root button:hover { background: #3a475a; }
.tomie-root button:disabled { opacity: .45; cursor: default; }
.tomie-cells { display: flex; flex-wrap: wrap; gap: 5px; margin: 14px 0; }
.tomie-cells span { padding: 7px; border: 1px solid #455167; }
.tomie-cells .current { border-color: #f5b77f; color: #ffd2a8; }
.tomie-toolbar { display: flex; flex-wrap: wrap; justify-content: center; background: #121620; }
.tomie-status { padding: 3px 12px; min-height: 20px; color: #ffd4ab; font-size: 13px; }
.tomie-modal { color: #f6f2ec; background: #171e2b; border: 1px solid #6d7c94; border-radius: 12px; max-width: min(900px, 92vw); width: 80vw; max-height: 80vh; }
.tomie-modal::backdrop { background: #0009; }
.tomie-preview { display: block; max-width: 100%; max-height: 45vh; }
.tomie-mini { z-index: 50; }
.tomie-mini-controls { position: absolute; z-index: 51; bottom: 15%; left: 40%; }
.tomie-root [hidden] { display: none !important; }
.tomie-effect { position: absolute; inset: 0; }
.tomie-symbol { display: grid; place-items: center; color: #ff90a0; font-size: 120px; }
.tomie-caption,.tomie-ambient { position: absolute; left: 59%; top: 14%; right: 4%; white-space: pre-wrap; background: #141a2be6; padding: 14px; border-radius: 10px; font-size: clamp(12px, 1.7vw, 25px); }
.tomie-caption { top: 35%; bottom: auto; }
.tomie-glitch { background: repeating-linear-gradient(0deg, #00ffa522 0 2px, transparent 2px 6px); animation: tomie-glitch .14s steps(2) infinite; }
.tomie-drop { position: absolute; top: -10%; width: 1px; height: 4%; background: #bed9ff66; transform: rotate(19deg); animation: tomie-rain 1.14s linear infinite; }
.tomie-shake { animation: tomie-shake .4s linear; }
@keyframes tomie-shake { 25%,75% { translate: 6px 3px; } 50%,90% { translate: -6px -3px; } }
@keyframes tomie-glitch { to { translate: 5px -2px; opacity: .3; } }
@keyframes tomie-rain { to { top: 110%; } }
+4
View File
@@ -89,11 +89,15 @@ export interface ScriptModule {
file: string;
labels: Label[];
anchors?: Record<string, Anchor>;
nani?: { name: string; labels: Record<string, number>; label_depths: Record<string, number>; label_ids?: Record<string, string> };
}
export interface GameData {
runtime?: string;
tomie?: Omit<import("./tomie-player").TomieContent, "scripts" | "resources">;
engine?: string;
naniExpressions?: Record<string, import("./naninovel").NaniAst>;
naniResources?: Record<string, Record<string, { tid: string; text: string }[]>>;
format: string;
title: string | null;
resolution: [number, number];
+13
View File
@@ -56,6 +56,19 @@ test("fade-in respects live volume changes and stays muted at zero", () => {
assert.equal(elements[0].volume, 0);
});
test("script track gain multiplies the mixer and survives fades and queued clips", () => {
const audio = system();
audio.play("voice", ["first", "second"], { volume: 0.25, fadein: 2 });
tick(1000);
assert.equal(elements[0].volume, 0.125);
audio.setVolumes({ ...volumes(), voice: 0.4 });
assert.equal(elements[0].volume, 0.05);
tick(1000);
elements[0].onended();
assert.equal(elements.at(-1).src, "second");
assert.equal(elements.at(-1).volume, 0.1);
});
test("outgoing tracks also obey the mixer during crossfade", () => {
const audio = system();
audio.play("music", ["old"], {});
+29
View File
@@ -38,3 +38,32 @@ test("Naninovel AST runs through the shared store and survives JSON saves", asyn
py.exec([{ op: "set", t: { k: "name", id: "score" }, v: { k: "nani", ast: bin("+", n("score"), c(4)) } }]);
assert.equal(JSON.parse(JSON.stringify(store)).score, 6);
});
test("the player presents translated Naninovel choices and jumps after selection", async () => {
const { Engine } = await load("engine");
class Element {
children = [];
style = {};
classList = { add() {}, remove() {} };
append(child) { this.children.push(child); }
set textContent(value) { this.children = value ? [value] : []; }
}
const previousDocument = globalThis.document;
globalThis.document = { createElement: () => new Element() };
try {
const engine = Object.create(Engine.prototype);
Object.assign(engine, { store: {}, game: { engine: "naninovel", naniExpressions: {} },
tr: { line: (_tid, text) => text === "Again" ? "Ещё раз" : text },
menuEl: new Element(), baseSize: 28, interactionStart() {}, setSkip() {},
advance() {}, jump(target) { this.chosenTarget = target; },
wait(setup) { return new Promise((resolve) => { setup(resolve); this.menuEl.children[0].onclick({ stopPropagation() {} }); }); } });
await engine.exec({ op: "nani_add_choice", what: "Again", tid: "choice-id", target: "nani:Opening", handler: "" });
assert.equal(JSON.parse(JSON.stringify(engine.store)).__nani_choices[0].tid, "choice-id");
await engine.exec({ op: "nani_menu" });
assert.equal(engine.menuEl.children[0].children[0], "Ещё раз");
assert.equal(engine.chosenTarget, "nani:Opening");
assert.deepEqual(engine.store.__nani_choices, []);
} finally {
globalThis.document = previousDocument;
}
});
+94
View File
@@ -0,0 +1,94 @@
// Optional browser integration: PGAMES_PLAYWRIGHT_PATH points at a Playwright
// installation; its matching Chromium must already be installed.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath, pathToFileURL } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
test("native Tomie UI plays, saves inside a call, restores and presents the board", {
skip: !process.env.PGAMES_PLAYWRIGHT_PATH,
}, async () => {
const { chromium } = await import(pathToFileURL(process.env.PGAMES_PLAYWRIGHT_PATH + "/index.mjs").href);
const entry = fileURLToPath(new URL("../src/player/tomie-player.ts", import.meta.url));
const bundle = await build({ entryPoints: [entry], bundle: true, write: false, format: "iife", globalName: "NativeTomie",
platform: "browser", loader: { ".css": "empty" }, logLevel: "silent" });
const browser = await chromium.launch({ headless: true, executablePath: process.env.PGAMES_CHROMIUM_EXECUTABLE });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const faults = [];
page.on("pageerror", (error) => faults.push(error.message));
try {
await page.goto("about:blank");
await page.setContent('<style>body{margin:0}#host{height:800px}</style><div id="host"></div>');
await page.addStyleTag({ content: readFileSync(new URL("../src/player/tomie.css", import.meta.url), "utf8") });
await page.addScriptTag({ content: bundle.outputFiles[0].text });
await page.evaluate(() => {
const c = (op, args = {}) => ({ op, args });
const script = (commands, labels = {}) => ({ commands, labels });
const content = { defaults: { day: 1, money: 100, iWillpower: 100, dActionPoint: 4 }, function_data: {},
initial_inventory: { coin: 1 }, start: { script: "StartGameSetting", label: "Start" }, resources: {}, board_routes: {},
media: { backgrounds: {}, characters: {}, textures: {}, videos: {}, audio: {}, fonts: {} },
scripts: {
StartGameSetting: script([c("PrintText", { Text: "Hello" }), c("Gosub", { Path: "Sub" }),
c("InputCustomVariable", { VariableName: "name", Summary: "Your name" }),
c("AddChoice", { ChoiceSummary: "Choose", OnSelected: { template: '@set answer="{name}"' } }), c("Stop"),
c("Spawn", { Path: "Rain" }), c("PrintText", { Text: { template: "Saved {answer}" } }), c("Goto", { Path: "AppScreen" })], { Start: 0 }),
Sub: script([c("PrintText", { Text: "Subroutine" }), c("Return")]),
AppScreen: script([c("Stop")]),
UseItem: script([c("RemoveItem", { ItemId: "coin" }), c("PrintText", { Text: "Used coin" }),
c("Spawn", { Path: "PregMiniGame" }), c("Stop")], { coin: 0 }),
Common2: script([c("PrintText", { Text: "Won" }), c("Goto", { Path: "AppScreen" }),
c("PrintText", { Text: "Lost" }), c("Goto", { Path: "AppScreen" })], { PregNO: 0, PregOK: 2 }),
} };
const slots = new Map();
const api = { list: async () => [...slots].map(([slot]) => ({ slot, title: "Saved" })),
put: async (slot, _title, data) => slots.set(slot, structuredClone(data)),
get: async (slot) => structuredClone(slots.get(slot)), remove: async (slot) => slots.delete(slot) };
window.native = new NativeTomie.TomiePlayer(document.querySelector("#host"), content, { versionId: 1, revision: "test", files: {} },
api, { line: (_tid, text) => text, str: (text) => text }, () => {});
window.slots = slots; window.native.start();
});
await page.locator(".tomie-dialogue").click();
await page.getByRole("button", { name: "Сохранить", exact: true }).click();
await page.getByRole("button", { name: "quick · Пусто", exact: true }).click();
assert.equal(await page.evaluate(() => window.slots.get("quick").runtime.stack.length), 1);
await page.locator(".tomie-dialogue").click();
await page.locator("dialog input").fill("Player");
await page.getByRole("button", { name: "Продолжить", exact: true }).click();
await page.getByRole("button", { name: "Choose", exact: true }).click();
assert.equal(await page.locator(".tomie-text").textContent(), "Saved Player");
assert.equal(await page.locator(".tomie-drop").count(), 140);
await page.getByRole("button", { name: "Загрузить", exact: true }).click();
await page.getByRole("button", { name: "quick · Saved", exact: true }).click();
assert.equal(await page.locator(".tomie-text").textContent(), "Subroutine");
await page.locator(".tomie-dialogue").click();
await page.locator("dialog input").fill("Restored");
await page.getByRole("button", { name: "Продолжить", exact: true }).click();
await page.getByRole("button", { name: "Choose", exact: true }).click();
await page.locator(".tomie-dialogue").click();
assert.match(await page.locator(".tomie-board").textContent(), /День 1/);
await page.getByRole("button", { name: "Инвентарь", exact: true }).click();
await page.getByRole("button", { name: "coin ×1", exact: true }).click();
assert.equal(await page.locator(".tomie-text").textContent(), "Used coin");
assert.equal(await page.evaluate(() => window.native.vm.evaluator.state.inventory.coin), 0);
await page.locator(".tomie-dialogue").click();
await page.getByRole("button", { name: "Начать", exact: true }).click();
await page.evaluate(() => window.native.mini.update(0.5));
await page.getByRole("button", { name: "Сохранить", exact: true }).click();
await page.getByRole("button", { name: "8 · Пусто", exact: true }).click();
assert.ok(await page.evaluate(() => window.slots.get("8").mini.elapsed >= 0.5));
await page.evaluate(() => {
for (let step = 0; step < 1300; step++) { window.native.mini.pulse(); window.native.mini.update(1 / 60); }
window.native.drawMini();
});
await page.getByRole("button", { name: "Продолжить", exact: true }).click();
assert.equal(await page.locator(".tomie-text").textContent(), "Won");
await page.getByRole("button", { name: "Загрузить", exact: true }).click();
await page.getByRole("button", { name: "8 · Saved", exact: true }).click();
assert.equal(await page.evaluate(() => window.native.mini.state.result), "running");
assert.equal(await page.locator(".tomie-mini").isVisible(), true);
assert.deepEqual(faults, []);
await page.evaluate(() => window.native.destroy());
assert.equal(await page.locator(".tomie-root").count(), 0);
} finally { await browser.close(); }
});
+224
View File
@@ -0,0 +1,224 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { build } from "esbuild";
async function load(name) {
const result = await build({ entryPoints: [fileURLToPath(new URL(`../src/player/${name}.ts`, import.meta.url))],
bundle: true, write: false, format: "esm", platform: "node", logLevel: "silent" });
return import(`data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString("base64")}`);
}
const { TomieFunctions } = await load("tomie-functions");
const { NaniRuntime } = await load("nani-runtime");
const { parseNani } = await load("nani-parser");
const { TomieScene } = await load("tomie-scene");
const { TomieMinigame } = await load("tomie-minigame");
const command = (op, args = {}) => ({ op, args });
test("Tomie functions preserve source formulas, string lists and saved RNG", () => {
let store = {};
const helpers = new TomieFunctions({ store: () => store, tables: { names: ["A", "B"] }, hasLabel: () => false, hasVideo: () => false, link() {} });
const f = helpers.functions;
assert.equal(f.WillpowerDamage(100, 50, 20, 0, 100), 83);
assert.equal(f.RoundValue(2.5), 2); assert.equal(f.RoundValue(3.5), 4);
assert.equal(f.RoundValue(-2.5), -2); assert.equal(f.RoundValue(-3.5), -4);
assert.equal(f.AddListString2("A@B", "B"), "A@B");
assert.equal(f.AddListString2("A", "B"), "A@B");
f.NewRandList(); f.AddRandList("A", 2); f.AddRandList("B");
assert.equal(f.MergeRandList(), "A&A&B");
helpers.state.rng = 12345;
const saved = structuredClone(store);
const first = [f.Random(1, 6), f.Random("A", "B", "C"), f.GetRandList()];
store = saved;
assert.deepEqual([f.Random(1, 6), f.Random("A", "B", "C"), f.GetRandList()], first);
});
test("runtime parser supports the recovered wrapped strings and lazy If", () => {
assert.deepEqual(parseNani('\\"hello\\"', new Set()), ["const", "hello"]);
assert.equal(parseNani('If(true, 1, 2)', new Set())[0], "if");
for (const invalid of ['globalThis.alert(1)', 'constructor()', 'a[0]', 'x; 2']) assert.throws(() => parseNani(invalid, new Set()));
});
function fixture() {
return { start: { script: "Main", label: "Start" }, defaults: { points: 1 }, function_data: {},
scripts: { Main: { labels: { Start: 0, End: 5 }, commands: [
command("SetCustomVariable", { Expression: "points+=2" }),
command("Gosub", { Path: "Sub" }),
command("AddChoice", { ChoiceSummary: "Spend", OnSelected: ["@removeItem coin amount:1", "@set points=points+3"], GotoPath: ".End" }),
command("Stop"), command("PrintText", { Text: "unreachable" }),
command("PrintText", { Text: { template: "Score {points}" } }), command("ExitToTitle") ] },
Sub: { labels: {}, commands: [command("PrintText", { Text: "Subroutine" }), command("Return")] } }, initial_inventory: { coin: 1 } };
}
test("Naninovel call frames, choice continuations and inventory survive a save", () => {
const events = [];
const vm = new NaniRuntime(fixture(), (kind, data) => events.push({ kind, data }));
vm.start(); assert.equal(vm.waiting, "text"); assert.equal(vm.stack.length, 1);
const saved = vm.snapshot();
const restored = new NaniRuntime(fixture(), (kind, data) => events.push({ kind, data }));
assert.equal(restored.restore(saved), true);
restored.advance(); assert.equal(restored.waiting, "choice");
const atChoice = restored.snapshot();
assert.equal(restored.restore(atChoice), true);
restored.choose(0);
assert.equal(restored.variables.points, 6);
assert.equal(restored.evaluator.state.inventory.coin, 0);
assert.equal(restored.waiting, "text");
assert.equal(events.findLast((event) => event.kind === "text").data.text, "Score 6");
restored.advance(); assert.equal(restored.waiting, "complete");
assert.equal(restored.restore({ ...saved, cursor: 9999 }), false);
});
test("native conditional seeking handles gotos across block boundaries and EndIf no-ops", () => {
const data = fixture();
data.scripts.Main = { labels: { Start: 0, Outside: 5 }, commands: [
command("BeginIf", { Expression: "true" }), command("Goto", { Path: ".Outside" }),
command("EndIf"), command("PrintText", { Text: "unreachable" }), command("EndIf"),
command("EndIf"), command("BeginIf", { Expression: "false" }), command("BeginIf", { Expression: "true" }),
command("PrintText", { Text: "nested unreachable" }), command("EndIf"), command("ElseIf", { Expression: "true" }),
command("PrintText", { Text: "selected" }), command("Else"), command("PrintText", { Text: "wrong else" }),
command("EndIf"), command("PrintText", { Text: "done" }) ] };
const texts = [], vm = new NaniRuntime(data, (kind, value) => { if (kind === "text") texts.push(value.text); });
vm.start(); assert.deepEqual(texts, ["selected"]); vm.advance(); assert.deepEqual(texts, ["selected", "done"]);
});
test("Tomie message triggers preserve anonymous threads and remove by identity", () => {
const vm = new NaniRuntime(fixture(), () => {});
vm.variables = { messageString: "Anonymous@hello@id1^Other@old@id2" };
vm.changed("newMessage", "Anonymous@new@id3");
assert.equal(vm.variables.messageString, "Anonymous@new@id3^Anonymous@hello@id1^Other@old@id2");
vm.changed("messageChanged", "id1#remove");
assert.equal(vm.variables.messageString, "Anonymous@new@id3^Other@old@id2");
});
test("dynamic OnSelected templates are resolved before parsing and preserve the chosen value", () => {
const data = fixture();
data.defaults.answer = "Tomie";
data.scripts.Main.commands = [command("AddChoice", { ChoiceSummary: "Choose", OnSelected: { template: '@set result="{answer}"' } }),
command("Stop"), command("PrintText", { Text: { template: "Selected {result}" } })];
const vm = new NaniRuntime(data, () => {});
vm.start();
assert.equal(vm.choiceSupported(vm.choices[0]), true);
vm.choose(0);
assert.equal(vm.waiting, "text");
assert.equal(vm.variables.result, "Tomie");
});
test("Tomie scene snapshots retain actor placement, media and effects without replaying the VM", () => {
const media = { backgrounds: { city: "bg" }, characters: { "tomie/home": { file: "actor", size: [700, 1080] } },
audio: { song: "music" }, videos: { clip: "video" }, textures: {}, fonts: {} };
const stage = new TomieScene(media);
stage.apply("ModifyBackground", { AppearanceAndTransition: { name: "city" } });
stage.apply("ModifyCharacter", { IdAndAppearance: "Tomie.home", ScenePosition: [33, 0, 1], Scale: [-1, 1] });
stage.apply("ModifyCharacter", { IdAndAppearance: "Tomie", ScenePosition: [60, 0] });
stage.apply("PlayBgm", { BgmPath: "Song", Loop: 1 });
stage.apply("variable_changed", { name: "videoBack", value: "Clip@0@half@right" });
stage.apply("Spawn", { Path: "Rain" });
stage.apply("text", { text: "Hello", author: "Tomie", reset: true });
const saved = stage.snapshot();
stage.apply("HideAllCharacters", {});
stage.apply("StopBgm", {});
const restored = new TomieScene(media);
restored.restore(saved);
assert.deepEqual(restored.state.actors.Tomie.scale, [-1, 1]);
assert.deepEqual(restored.state.actors.Tomie.position, [60, 0]);
assert.equal(restored.state.actors.Tomie.visible, true);
assert.equal(restored.state.bgm.file, "music");
assert.equal(restored.state.video.file, "video");
assert.ok(Object.hasOwn(restored.state.effects, "Rain"));
assert.equal(restored.state.text, "Hello");
assert.throws(() => restored.apply("ModifyCharacter", { IdAndAppearance: "Tomie.missing" }), /не найден образ/);
assert.throws(() => restored.apply("PlaySfx", { SfxPath: "missing" }), /не найден ресурс/);
assert.equal(restored.apply("NativePlugin", {}), false);
});
test("native minigame pauses, saves mid-round and takes both original result paths", () => {
const game = new TomieMinigame(() => 0.5);
game.start();
for (let step = 0; step < 120; step++) game.update(1 / 60);
const saved = game.snapshot();
game.update(0.5, true);
assert.deepEqual(game.snapshot(), saved);
assert.ok(saved.particles.length > 0);
const resumed = new TomieMinigame(() => 0.5);
resumed.restore(saved);
for (let step = 0; step < 1200; step++) {
resumed.pulse(); resumed.update(1 / 60);
game.pulse(); game.update(1 / 60);
}
assert.equal(resumed.state.result, "won");
assert.deepEqual(resumed.snapshot(), game.snapshot());
const lost = new TomieMinigame(() => 0.5);
lost.start();
for (let step = 0; step < 1200 && lost.state.result === "running"; step++) lost.update(1 / 60);
assert.equal(lost.state.result, "lost");
});
test("the original Unity extraction runs character creation through to the board", { skip: !process.env.PGAMES_TOMIE_RUNTIME }, () => {
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_RUNTIME, "utf8"));
const faults = [];
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") faults.push(value.message); });
vm.start(129);
for (let step = 0; step < 10000 && !vm.onBoard && !["error", "complete"].includes(vm.waiting); step++) {
if (["text", "timer", "timed_choice"].includes(vm.waiting)) vm.advance();
else if (vm.waiting === "input") vm.submitInput(String(vm.resolve(vm.last.args.VariableName)), "Player");
else if (vm.waiting === "choice") {
const index = vm.choices.findIndex((choice) => vm.choiceSupported(choice));
assert.ok(index >= 0, `No available choices in ${vm.script}:${vm.last.line}`); vm.choose(index);
} else throw new Error(`Unexpected wait ${vm.waiting} in ${vm.script}:${vm.last.line}`);
}
assert.deepEqual(faults, []);
assert.equal(vm.onBoard, true);
});
test("real recovered Tomie opening reaches the board without an expression error", { skip: !process.env.PGAMES_TOMIE_FIXTURE }, () => {
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_FIXTURE, "utf8"));
data.function_data = JSON.parse(readFileSync(new URL("function_data.json", `file:///${process.env.PGAMES_TOMIE_FIXTURE.replaceAll('\\', '/')}`), "utf8"));
let fault;
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") fault = value.message; });
vm.start();
for (let step = 0; step < 10000 && !["board", "error", "complete"].includes(vm.waiting); step++) {
if (["text", "timer", "timed_choice"].includes(vm.waiting)) vm.advance();
else if (vm.waiting === "choice") {
const index = vm.choices.findIndex((choice) => vm.choiceSupported(choice));
assert.ok(index >= 0); vm.choose(index);
} else throw new Error("Unexpected wait: " + vm.waiting);
}
assert.equal(fault, undefined);
assert.equal(vm.waiting, "board");
});
test("real Tomie runs seven original day/night chains for three seeds", { skip: !process.env.PGAMES_TOMIE_FIXTURE }, () => {
const data = JSON.parse(readFileSync(process.env.PGAMES_TOMIE_FIXTURE, "utf8"));
data.function_data = JSON.parse(readFileSync(new URL("function_data.json", `file:///${process.env.PGAMES_TOMIE_FIXTURE.replaceAll('\\', '/')}`), "utf8"));
data.start = data.board_start;
const isBoard = (vm) => vm.waiting === "board" || (vm.waiting === "choice" && vm.script === "AppScreen" && vm.last.line === 97);
function settle(vm) {
const seen = new Map();
for (let step = 0; step < 3000; step++) {
if (isBoard(vm) || vm.waiting === "complete") return;
assert.notEqual(vm.waiting, "error", vm.lastFault);
if (["text", "timer"].includes(vm.waiting)) vm.advance();
else if (["choice", "timed_choice"].includes(vm.waiting)) {
const key = vm.script + ":" + vm.last.line, count = seen.get(key) ?? 0;
seen.set(key, count + 1);
const enabled = vm.choices.map((choice, i) => ({ choice, i })).filter(({ choice }) => vm.choiceSupported(choice));
assert.ok(enabled.length > 0, "All choices disabled: " + key);
const exit = vm.script === "HomeActivity" || count > 1 ? enabled.find(({ choice }) =>
["do nothing", "return", "leave", "end", "stop", "i got it", "decline", "refuse"].some((word) => choice.ChoiceSummary.toLowerCase().includes(word))) : undefined;
vm.choose((exit ?? enabled[count % enabled.length]).i);
} else if (vm.waiting === "input") vm.submitInput(String(vm.last.args.VariableName ?? "cheatCode"), "");
else throw new Error("Unexpected wait " + vm.waiting + " in " + vm.script);
}
throw new Error("Route did not settle: " + vm.script + ":" + vm.last.line);
}
for (const seed of [1, 7, 42]) {
const vm = new NaniRuntime(data, (kind, value) => { if (kind === "error") vm.lastFault = value.message; });
vm.fastWaits = true; vm.start(seed); settle(vm);
for (let day = 0; day < 7; day++) {
vm.runExternal("DicePlay", "DayEnd"); settle(vm);
assert.ok(isBoard(vm));
}
}
});