import { validate2D, validateImage, type ImageSettings } from "./two-d.ts"; export type Vec3 = [number, number, number]; export type Component = Record; export interface Transform { position: Vec3; rotation: Vec3; scale: Vec3; } export interface Entity { id: string; name: string; parentId: string | null; enabled: boolean; transform: Transform; components: Record; } export interface Geometry { positions: number[]; indices: number[]; normals?: number[]; uvs?: number[]; } export interface Asset { id: string; name: string; kind: "model" | "geometry" | "prefab" | "image"; image?: ImageSettings; uri?: string; geometry?: Geometry; entities?: Entity[]; metadata?: any; } export interface ScriptAsset { id: string; name: string; source: string; fields: Record< string, { type: "number" | "boolean" | "string" | "entity"; default: any; label?: string; min?: number; max?: number; } >; } export interface Project { format: "forma"; version: 1; id: string; name: string; revision: number; activeSceneId: string; scenes: { id: string; name: string; mode?: "2d" | "3d"; entities: Entity[]; }[]; assets: Asset[]; scripts: ScriptAsset[]; settings: { physics2d?: { gravity: [number, number] }; background: string; ambient: number; shadows: boolean; renderScale: number; rendering?: { toneMapping?: boolean; exposure?: number; contrast?: number; defaultLights?: boolean; }; controls?: Partial< Record<"attack" | "jump" | "dash" | "sprint" | "reset", string[]> >; presentation?: { title?: string; instructions?: string; start?: { title: string; body: string }; accent?: string; }; }; } export interface Command { op: string; args: any; } export interface Transaction { commands: Command[]; expectedRevision?: number; requestId?: string; label?: string; source?: string; } export const clone = (v: T): T => structuredClone(v); export const uid = (p = "obj") => p + "_" + crypto.randomUUID().replaceAll("-", "").slice(0, 12); export const transform = (): Transform => ({ position: [0, 0, 0], rotation: [0, 0, 0], scale: [1, 1, 1], }); export const entity = ( name: string, components: Entity["components"] = {}, position: Vec3 = [0, 0, 0], id = uid(), ): Entity => ({ id, name, parentId: null, enabled: true, transform: { ...transform(), position }, components, }); export function emptyProject(name = "Без названия"): Project { const id = uid("scene"); return { format: "forma", version: 1, id: uid("project"), name, revision: 0, activeSceneId: id, scenes: [{ id, name: "Основная сцена", entities: [] }], assets: [], scripts: [], settings: { background: "#dedbd2", ambient: 0.85, shadows: true, renderScale: 1, }, }; } export const activeScene = (p: Project) => p.scenes.find((s) => s.id === p.activeSceneId)!; const forbidden = new Set(["__proto__", "constructor", "prototype"]); export function assertJson(v: any, depth = 0) { if (depth > 28) throw Error("Слишком глубокая структура"); if ( v === undefined || typeof v === "function" || typeof v === "bigint" || (typeof v === "number" && !Number.isFinite(v)) ) throw Error("Требуются конечные JSON-данные"); if (v && typeof v === "object") for (const [k, n] of Object.entries(v)) { if (forbidden.has(k)) throw Error("Недопустимое имя свойства"); assertJson(n, depth + 1); } } export function deepMerge(a: any, b: any) { for (const [k, v] of Object.entries(b)) { if (forbidden.has(k)) throw Error("Недопустимое имя свойства"); if (v && typeof v === "object" && !Array.isArray(v)) a[k] = deepMerge( a[k] && typeof a[k] === "object" && !Array.isArray(a[k]) ? a[k] : {}, v, ); else a[k] = clone(v); } return a; } export function validateGeometry(g: Geometry) { if ( !g || !Array.isArray(g.positions) || !Array.isArray(g.indices) || g.positions.length < 9 || g.positions.length % 3 || g.indices.length < 3 || g.indices.length % 3 ) throw Error("Геометрия: positions и indices должны описывать треугольники"); if (g.positions.length > 900000 || g.indices.length > 1800000) throw Error("Лимит геометрии: 300 000 вершин, 600 000 треугольников"); if ( !g.positions.every(Number.isFinite) || !g.indices.every( (i) => Number.isInteger(i) && i >= 0 && i < g.positions.length / 3, ) ) throw Error("Некорректные вершины или индексы"); if ( g.normals && (g.normals.length !== g.positions.length || !g.normals.every(Number.isFinite)) ) throw Error("Некорректные нормали"); if ( g.uvs && (g.uvs.length !== (g.positions.length / 3) * 2 || !g.uvs.every(Number.isFinite)) ) throw Error("Некорректные UV"); } const identifier = (id: any) => typeof id === "string" && /^[a-zA-Z0-9_-]{1,100}$/.test(id); const color = (s: any) => typeof s === "string" && /^#[\da-fA-F]{6}$/.test(s); const vector = (v: any) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite); export function validateProject(p: Project) { assertJson(p); if (p?.format !== "forma" || p.version !== 1) throw Error("Поддерживается формат Forma v1"); if ( !identifier(p.id) || typeof p.name !== "string" || p.name.length > 200 || !Number.isInteger(p.revision) || p.revision < 0 ) throw Error("Некорректные метаданные"); if ( !Array.isArray(p.scenes) || !p.scenes.length || !p.scenes.some((s) => s.id === p.activeSceneId) || !Array.isArray(p.assets) || !Array.isArray(p.scripts) ) throw Error("Неполный проект"); if ( !p.settings || !color(p.settings.background) || !(p.settings.ambient >= 0 && p.settings.ambient <= 10) || !(p.settings.renderScale >= 0.4 && p.settings.renderScale <= 1.5) || typeof p.settings.shadows !== "boolean" ) throw Error("Некорректные настройки сцены"); const unique = (list: any[]) => { const ids = new Set(); for (const v of list) { if (!identifier(v.id) || ids.has(v.id)) throw Error("Требуется уникальный ID"); ids.add(v.id); } }; unique(p.scenes); unique(p.assets); unique(p.scripts); const nodes = (list: Entity[]) => { if (!Array.isArray(list) || list.length > 3000) throw Error("Лимит: 3000 объектов"); unique(list); const map = new Map(list.map((n) => [n.id, n])); for (const n of list) { if ( typeof n.name !== "string" || n.name.length > 200 || typeof n.enabled !== "boolean" || !n.components || Array.isArray(n.components) || !(n.parentId === null || identifier(n.parentId)) ) throw Error("Некорректный объект"); if ( !vector(n.transform?.position) || !vector(n.transform?.rotation) || !vector(n.transform?.scale) || n.transform.scale.some((v) => Math.abs(v) < 0.0001) ) throw Error("Некорректная трансформация"); for (const c of Object.values(n.components)) if (!c || typeof c !== "object" || Array.isArray(c)) throw Error("Компонент должен быть объектом"); validate2D(n, p, list); const c = n.components, m = c.mesh; if (m?.type === "custom") validateGeometry(m.geometry); if ( m?.assetId && !p.assets.some( (a) => a.id === m.assetId && a.kind !== "prefab" && a.kind !== "image", ) ) throw Error("Не найден ресурс " + m.assetId); if ( c.script?.scriptId && !p.scripts.some((s) => s.id === c.script.scriptId) ) throw Error("Не найден скрипт " + c.script.scriptId); if (c.material?.color && !color(c.material.color)) throw Error("Цвет должен быть #RRGGBB"); if ( c.material?.alpha !== undefined && (!Number.isFinite(c.material.alpha) || c.material.alpha < 0 || c.material.alpha > 1) ) throw Error("Прозрачность материала должна быть от 0 до 1"); if ( c.camera?.projection === "orthographic" && (!Number.isFinite(c.camera.orthoWidth) || c.camera.orthoWidth <= 0) ) throw Error("Ширина ортографической камеры должна быть положительной"); if (c.camera?.lookAt && !vector(c.camera.lookAt)) throw Error("Некорректная цель камеры"); if (m?.size && (!vector(m.size) || m.size.some((v: number) => v <= 0))) throw Error("Размеры должны быть положительными"); if ( c.collider?.size && (!vector(c.collider.size) || c.collider.size.some((v: number) => v <= 0)) ) throw Error("Размер коллайдера должен быть положительным"); if (c.collider?.radius !== undefined && c.collider.radius <= 0) throw Error("Радиус должен быть положительным"); let cur = n; const seen = new Set([n.id]); while (cur.parentId) { const parent = map.get(cur.parentId); if (!parent) throw Error("Родитель не найден"); if (seen.has(parent.id)) throw Error("Цикл в иерархии"); seen.add(parent.id); cur = parent; } } }; for (const s of p.scenes) { if (s.mode && !["2d", "3d"].includes(s.mode)) throw Error("Неизвестный режим сцены"); nodes(s.entities); } if ( p.settings.physics2d && (!Array.isArray(p.settings.physics2d.gravity) || p.settings.physics2d.gravity.length !== 2 || !p.settings.physics2d.gravity.every(Number.isFinite)) ) throw Error("Некорректная гравитация 2D"); for (const a of p.assets) { if ( typeof a.name !== "string" || !["model", "geometry", "prefab", "image"].includes(a.kind) ) throw Error("Некорректный ресурс"); if (a.kind === "image") validateImage(a); if (a.kind === "geometry") validateGeometry(a.geometry!); if (a.kind === "prefab") { nodes(a.entities!); if (a.entities!.filter((n) => !n.parentId).length !== 1) throw Error("Префабу нужен один корневой объект"); } if (a.uri && !a.uri.startsWith("data:")) { if ( !/^(\/|\.\/*)?assets\/[a-zA-Z0-9_.-]+\.(glb|gltf|png|jpg|jpeg|webp)$/i.test( a.uri, ) ) throw Error("Импортируйте ресурс в assets/, внешние пути запрещены"); } } for (const s of p.scripts) { if ( typeof s.source !== "string" || s.source.length > 250000 || !s.fields || typeof s.fields !== "object" ) throw Error("Некорректный скрипт"); for (const f of Object.values(s.fields)) { if ( !f || !["number", "boolean", "string", "entity"].includes(f.type) || typeof f.default !== (f.type === "entity" ? "string" : f.type) ) throw Error("Некорректные поля скрипта"); } } } export function remapEntityReferences( n: Entity, ids: Map, scripts: ScriptAsset[], ) { if (ids.has(n.components.camera?.targetId)) n.components.camera.targetId = ids.get(n.components.camera.targetId); if (ids.has(n.components.joint2d?.targetId)) n.components.joint2d.targetId = ids.get(n.components.joint2d.targetId); const binding = n.components.script, script = scripts.find((s) => s.id === binding?.scriptId); if (binding && script) for (const [k, f] of Object.entries(script.fields)) if (f.type === "entity") { const value = binding.params?.[k] ?? f.default; if (ids.has(value)) { binding.params ??= {}; binding.params[k] = ids.get(value); } } return n; } export const componentDefaults: Record = { sprite: { assetId: "", frame: 0, color: "#ffffff", alpha: 1, layer: 0, order: 0, flipX: false, flipY: false, lit: false, }, tilemap: { assetId: "", tileSize: [1, 1], width: 32, height: 18, cells: [], layer: 0, order: 0, collisions: false, }, collider2d: { shape: "box", size: [1, 1], offset: [0, 0], sensor: false, oneWay: false, membership: 1, mask: 65535, }, rigidbody2d: { type: "dynamic", mass: 1, friction: 0.5, restitution: 0, gravityScale: 1, lockRotation: true, ccd: true, }, character2d: { mode: "platformer", controls: true, speed: 5, jumpSpeed: 8, gravity: 20, autostep: 0.2, }, spriteAnimator: { autoplay: "", clips: [] }, joint2d: { type: "revolute", targetId: "", anchor: [0, 0], targetAnchor: [0, 0], length: 1, stiffness: 50, damping: 5, }, mesh: { type: "box", size: [1, 1, 1] }, material: { color: "#91a697", roughness: 0.8, metallic: 0, alpha: 1, unlit: false, }, collider: { shape: "box", size: [1, 1, 1], radius: 0.4 }, rigidbody: { type: "fixed", mass: 1, restitution: 0.1 }, character: { gravity: 24, autostep: 0.25 }, camera: { targetId: "", offset: [0, 13, -10], fov: 0.72 }, light: { color: "#fff1da", intensity: 2 }, animator: { idle: "Idle", run: "Run", attack: "Attack", death: "Death", speed: 1, }, data: {}, };