Files
2026-09-12 04:58:59 +03:00

285 lines
8.8 KiB
TypeScript

import {
type Project,
type Transaction,
type Command,
clone,
validateProject,
deepMerge,
entity,
uid,
remapEntityReferences,
} from "./schema.ts";
export class ProjectStore {
project: Project;
history: any[] = [];
private past: Project[] = [];
private future: Project[] = [];
private receipts = new Map<string, any>();
private listeners = new Set<() => void>();
constructor(p: Project) {
validateProject(p);
this.project = clone(p);
}
get canUndo() {
return !!this.past.length;
}
get canRedo() {
return !!this.future.length;
}
get snapshot() {
return this.project;
}
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => {
this.listeners.delete(fn);
};
};
private emit() {
for (const fn of this.listeners) fn();
}
transaction(tx: Transaction) {
if (tx.requestId && this.receipts.has(tx.requestId))
return clone(this.receipts.get(tx.requestId));
if (
tx.expectedRevision !== undefined &&
tx.expectedRevision !== this.project.revision
)
throw Error("REVISION_CONFLICT: current " + this.project.revision);
if (
!Array.isArray(tx.commands) ||
!tx.commands.length ||
tx.commands.length > 1000
)
throw Error("Требуется 1–1000 команд");
let next = clone(this.project);
const results = [];
for (const c of tx.commands) {
if (c.op === "project.replace") {
next = clone(c.args.project);
results.push({ id: next.id });
} else results.push(this.apply(next, c));
}
next.revision = this.project.revision + 1;
validateProject(next);
this.past.push(this.project);
if (this.past.length > 30) this.past.shift();
this.future = [];
this.project = next;
this.history.unshift({
revision: next.revision,
label: tx.label || tx.commands[0].op,
source: tx.source || "editor",
time: Date.now(),
});
this.history = this.history.slice(0, 100);
const result = { revision: next.revision, results };
if (tx.requestId) {
this.receipts.set(tx.requestId, result);
if (this.receipts.size > 200)
this.receipts.delete(this.receipts.keys().next().value!);
}
this.emit();
return result;
}
command(op: string, args: any, label?: string) {
return this.transaction({ commands: [{ op, args }], label });
}
undo() {
if (!this.past.length) return;
this.future.push(this.project);
this.project = { ...this.past.pop()!, revision: this.project.revision + 1 };
this.recordHistory("Отмена");
}
redo() {
if (!this.future.length) return;
this.past.push(this.project);
this.project = {
...this.future.pop()!,
revision: this.project.revision + 1,
};
this.recordHistory("Повтор");
}
private recordHistory(label: string) {
this.history.unshift({
revision: this.project.revision,
label,
source: "editor",
time: Date.now(),
});
this.history = this.history.slice(0, 100);
this.emit();
}
synchronize(p: Project) {
validateProject(p);
this.project = clone(p);
this.past = [];
this.future = [];
this.emit();
}
private apply(p: Project, { op, args: a }: Command): any {
const scene = p.scenes.find(
(s) => s.id === (a?.sceneId || p.activeSceneId),
);
const find = () => {
const n = scene?.entities.find((n) => n.id === a.id);
if (!n) throw Error("Объект не найден: " + a.id);
return n;
};
const subtree = (id: string) => {
const ids = new Set([id]);
let changed = true;
while (changed) {
changed = false;
for (const n of scene!.entities)
if (n.parentId && ids.has(n.parentId) && !ids.has(n.id)) {
ids.add(n.id);
changed = true;
}
}
return scene!.entities.filter((n) => ids.has(n.id));
};
switch (op) {
case "project.rename":
p.name = a.name;
return { name: a.name };
case "project.settings":
deepMerge(p.settings, a);
return p.settings;
case "scene.configure": {
if (!scene) throw Error("Сцена не найдена");
scene.mode = a.mode;
return { id: scene.id };
}
case "scene.create": {
const s = {
id: a.id || uid("scene"),
name: a.name || "Сцена",
...(a.mode ? { mode: a.mode } : {}),
entities: [],
};
p.scenes.push(s);
p.activeSceneId = s.id;
return { id: s.id };
}
case "scene.activate":
if (!p.scenes.some((s) => s.id === a.id))
throw Error("Сцена не найдена");
p.activeSceneId = a.id;
return { id: a.id };
case "scene.rename":
if (!scene) throw Error("Сцена не найдена");
scene.name = a.name;
return { id: scene.id };
case "node.create": {
if (!scene) throw Error("Сцена не найдена");
const n = a.entity
? clone(a.entity)
: entity(
a.name || "Объект",
a.components || {},
a.position || [0, 0, 0],
a.id,
);
if (a.parentId) n.parentId = a.parentId;
scene.entities.push(n);
return { id: n.id };
}
case "node.patch": {
const n = find();
if ("id" in a.patch || "parentId" in a.patch)
throw Error("Используйте node.reparent для иерархии");
deepMerge(n, a.patch);
return { id: n.id };
}
case "node.reparent": {
const n = find();
n.parentId = a.parentId || null;
if (a.transform) n.transform = clone(a.transform);
return { id: n.id };
}
case "node.delete": {
find();
const ids = new Set(subtree(a.id).map((n) => n.id));
scene!.entities = scene!.entities.filter((n) => !ids.has(n.id));
return { deleted: [...ids] };
}
case "node.duplicate": {
const root = find(),
copies = clone(subtree(root.id)),
ids = new Map(copies.map((n) => [n.id, uid()]));
for (const n of copies) {
remapEntityReferences(n, ids, p.scripts);
if (n.id === root.id) {
n.name += " — копия";
n.transform.position[0] += 1;
} else n.parentId = ids.get(n.parentId!)!;
n.id = ids.get(n.id)!;
}
scene!.entities.push(...copies);
return { id: ids.get(root.id) };
}
case "component.set":
if (
!/^[a-zA-Z][a-zA-Z0-9_]{0,70}$/.test(a.type) ||
["__proto__", "constructor", "prototype"].includes(a.type)
)
throw Error("Недопустимое имя компонента");
find().components[a.type] = clone(a.value);
return { id: a.id };
case "component.remove":
if (["__proto__", "constructor", "prototype"].includes(a.type))
throw Error("Недопустимое имя компонента");
delete find().components[a.type];
return { id: a.id };
case "asset.upsert": {
const i = p.assets.findIndex((n) => n.id === a.asset.id);
if (i >= 0) p.assets[i] = clone(a.asset);
else p.assets.push(clone(a.asset));
return { id: a.asset.id };
}
case "asset.delete":
p.assets = p.assets.filter((n) => n.id !== a.id);
return { id: a.id };
case "script.upsert": {
const i = p.scripts.findIndex((n) => n.id === a.script.id);
if (i >= 0) p.scripts[i] = clone(a.script);
else p.scripts.push(clone(a.script));
return { id: a.script.id };
}
case "prefab.create": {
const root = find(),
nodes = clone(subtree(root.id));
nodes.find((n) => n.id === root.id)!.parentId = null;
const id = a.assetId || uid("prefab");
p.assets.push({
id,
name: a.name || root.name,
kind: "prefab",
entities: nodes,
});
return { id };
}
case "prefab.instantiate": {
const asset = p.assets.find(
(a2) => a2.id === a.assetId && a2.kind === "prefab",
);
if (!asset?.entities) throw Error("Префаб не найден");
const nodes = clone(asset.entities),
ids = new Map(nodes.map((n) => [n.id, uid()]));
for (const n of nodes) {
remapEntityReferences(n, ids, p.scripts);
n.id = ids.get(n.id)!;
n.parentId = n.parentId ? ids.get(n.parentId)! : null;
if (!n.parentId && a.position)
n.transform.position = clone(a.position);
}
scene!.entities.push(...nodes);
return { id: nodes.find((n) => !n.parentId)!.id };
}
default:
throw Error("Неизвестная команда: " + op);
}
}
}