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(); 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); } } }