478 lines
14 KiB
TypeScript
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: {},
|
|
};
|