285 lines
8.8 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
}
|