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

478 lines
14 KiB
TypeScript

import { validate2D, validateImage, type ImageSettings } from "./two-d.ts";
export type Vec3 = [number, number, number];
export type Component = Record<string, any>;
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<string, Component>;
}
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 = <T>(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<string, string>,
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<string, Component> = {
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: {},
};