import type { Asset, Entity, Project } from "./schema.ts"; export interface SpriteFrame { name: string; x: number; y: number; width: number; height: number; pivot: [number, number]; } export interface ImageSettings { width: number; height: number; pixelsPerUnit: number; filter: "nearest" | "linear"; frames: SpriteFrame[]; } export interface TileCell { x: number; y: number; frame: number; solid?: boolean; } export const is2D = (n: Entity) => !!( n.components.sprite || n.components.tilemap || n.components.collider2d || n.components.rigidbody2d ); const finite = (n: any) => typeof n === "number" && Number.isFinite(n); const pair = (v: any) => Array.isArray(v) && v.length === 2 && v.every(finite); const positive = (v: any) => finite(v) && v > 0; const integer = (v: any, min: number, max: number) => Number.isInteger(v) && v >= min && v <= max; const fail = (s: string): never => { throw Error("2D: " + s); }; export function validateImage(a: Asset) { const im = a.image; if (!a.uri) fail("изображению нужен URI"); if ( !im || !integer(im.width, 1, 16384) || !integer(im.height, 1, 16384) || !positive(im.pixelsPerUnit) || !["nearest", "linear"].includes(im.filter) || !Array.isArray(im.frames) || !im.frames.length || im.frames.length > 4096 ) fail("некорректные настройки изображения"); for (const f of im!.frames) if ( typeof f.name !== "string" || !integer(f.x, 0, im!.width - 1) || !integer(f.y, 0, im!.height - 1) || !integer(f.width, 1, im!.width - f.x) || !integer(f.height, 1, im!.height - f.y) || !pair(f.pivot) || f.pivot.some((v) => v < 0 || v > 1) ) fail("кадр выходит за изображение или неверный pivot"); } export function validate2D(n: Entity, p: Project, list: Entity[]) { const c = n.components, image = (id: string) => p.assets.find((a) => a.id === id && a.kind === "image"); if (c.sprite && c.mesh) fail("Sprite и Mesh должны быть на разных объектах общей сцены"); if (c.sprite && c.tilemap) fail("Sprite и Tilemap должны быть отдельными объектами"); for (const type of ["sprite", "tilemap"]) if (c[type]) { const v = c[type], a = v.assetId ? image(v.assetId) : null; if (v.assetId && !a) fail("не найдено изображение " + v.assetId); if (v.layer !== undefined && !integer(v.layer, -100, 100)) fail("слой: от -100 до 100"); if (v.order !== undefined && !integer(v.order, -10000, 10000)) fail("порядок: от -10000 до 10000"); if ( v.alpha !== undefined && (!finite(v.alpha) || v.alpha < 0 || v.alpha > 1) ) fail("прозрачность: 0–1"); if (v.color !== undefined && !/^#[\da-f]{6}$/i.test(v.color)) fail("цвет #RRGGBB"); if (type === "sprite") { if (v.size !== undefined && (!pair(v.size) || !v.size.every(positive))) fail("размер спрайта должен быть положительным"); if ( v.frame !== undefined && !integer(v.frame, 0, Math.max(0, (a?.image?.frames.length || 1) - 1)) ) fail("нет такого кадра"); } else { if ( !pair(v.tileSize) || !v.tileSize.every(positive) || !integer(v.width, 1, 512) || !integer(v.height, 1, 512) || !Array.isArray(v.cells) || v.cells.length > 65536 ) fail("неверная Tilemap (до 65536 тайлов)"); const keys = new Set(); for (const cell of v.cells) { const key = cell.x + "," + cell.y; if ( !integer(cell.x, 0, v.width - 1) || !integer(cell.y, 0, v.height - 1) || !integer( cell.frame, 0, Math.max(0, (a?.image?.frames.length || 1) - 1), ) || keys.has(key) ) fail("неверный или повторный тайл"); keys.add(key); } } } if (c.spriteAnimator) { if ( !c.sprite || !Array.isArray(c.spriteAnimator.clips) || c.spriteAnimator.clips.length > 100 ) fail("аниматору нужен спрайт и список клипов"); const count = image(c.sprite.assetId)?.image?.frames.length || 1, names = new Set(); for (const clip of c.spriteAnimator.clips) { if ( typeof clip.name !== "string" || !clip.name || names.has(clip.name) || !positive(clip.fps) || clip.fps > 120 || !Array.isArray(clip.frames) || !clip.frames.length || clip.frames.length > 4096 || !clip.frames.every((f: any) => integer(f, 0, count - 1)) ) fail("неверный клип спрайта"); names.add(clip.name); } if (c.spriteAnimator.autoplay && !names.has(c.spriteAnimator.autoplay)) fail("клип автозапуска не найден"); } if (c.collider2d || c.rigidbody2d || c.character2d || c.tilemap?.collisions) { if (c.collider || c.rigidbody || c.character) fail("нельзя смешивать физические тела 2D и 3D на одном объекте"); let parent: Entity | undefined = n; const ancestors = new Set(); while (parent) { if (ancestors.has(parent.id)) fail("цикл в иерархии"); ancestors.add(parent.id); if ( Math.abs(parent.transform.rotation[0]) + Math.abs(parent.transform.rotation[1]) > 1e-6 ) fail("физика XY допускает только вращение вокруг Z"); if ( parent !== n && Math.abs(parent.transform.scale[0] - parent.transform.scale[1]) > 1e-6 ) fail("родителям физики 2D нужен одинаковый масштаб X/Y"); parent = list.find((e) => e.id === parent!.parentId); } const r = c.rigidbody2d; if (r) { if (!["fixed", "dynamic", "kinematic"].includes(r.type)) fail("неверный тип тела"); for (const k of [ "mass", "friction", "restitution", "linearDamping", "angularDamping", ]) if (r[k] !== undefined && (!finite(r[k]) || r[k] < 0)) fail("неверное свойство тела " + k); if (r.gravityScale !== undefined && !finite(r.gravityScale)) fail("неверная гравитация"); } const col = c.collider2d; if (col) { if (!["box", "circle", "capsule", "polygon"].includes(col.shape)) fail("неверная форма коллайдера"); if (col.size && (!pair(col.size) || !col.size.every(positive))) fail("неверный размер коллайдера"); if (col.offset && !pair(col.offset)) fail("неверное смещение"); if (col.radius !== undefined && !positive(col.radius)) fail("радиус > 0"); if (col.height !== undefined && !positive(col.height)) fail("высота > 0"); for (const k of ["membership", "mask"]) if (col[k] !== undefined && !integer(col[k], 0, 65535)) fail("маска: 0–65535"); if (col.shape === "polygon") { if ( !Array.isArray(col.points) || col.points.length < 3 || col.points.length > 64 || !col.points.every(pair) ) fail("полигон: 3–64 точки"); let sign = 0; for (let i = 0; i < col.points.length; i++) { const a = col.points[i], b = col.points[(i + 1) % col.points.length], d = col.points[(i + 2) % col.points.length], cross = (b[0] - a[0]) * (d[1] - b[1]) - (b[1] - a[1]) * (d[0] - b[0]); if (Math.abs(cross) > 1e-8) { if (sign && Math.sign(cross) !== sign) fail( "полигон должен быть выпуклым с последовательным порядком вершин", ); sign = Math.sign(cross); } } if (!sign) fail("полигон имеет нулевую площадь"); for (let i = 0; i < col.points.length; i++) { const a = col.points[i], b = col.points[(i + 1) % col.points.length]; for (const d of col.points) { const cross = (b[0] - a[0]) * (d[1] - a[1]) - (b[1] - a[1]) * (d[0] - a[0]); if (cross * sign < -1e-8) fail("полигон должен быть выпуклым без самопересечений"); } } } } if (col?.oneWay) { let ancestor: Entity | undefined = n; while (ancestor) { if (Math.abs(ancestor.transform.rotation[2]) > 1e-6) fail("односторонняя платформа должна быть горизонтальной"); ancestor = list.find((e) => e.id === ancestor!.parentId); } if (col.shape !== "box" || (r && r.type !== "fixed") || col.sensor) fail("односторонняя платформа: неподвижный прямоугольник без триггера"); } if (r && !col && !c.tilemap?.collisions) fail("телу 2D нужен Collider2D или коллизии Tilemap"); if (c.character2d && (!col || r?.type !== "kinematic")) fail("Character2D требует Collider2D и кинематическое тело"); if (c.character2d) { for (const k of ["speed", "jumpSpeed", "gravity", "autostep"]) if ( c.character2d[k] !== undefined && (!finite(c.character2d[k]) || c.character2d[k] < 0) ) fail("неверный параметр персонажа"); if (!["platformer", "topDown"].includes(c.character2d.mode)) fail("режим персонажа: platformer или topDown"); } if (c.tilemap?.collisions && r && r.type !== "fixed") fail("физическая Tilemap должна быть неподвижной"); } if (c.joint2d) { const j = c.joint2d; if ( !["fixed", "revolute", "rope", "spring"].includes(j.type) || !c.rigidbody2d || !c.collider2d || !list.some( (e) => e.id === j.targetId && e.id !== n.id && e.components.rigidbody2d && e.components.collider2d, ) ) fail("шарниру нужны два различных тела 2D"); if (!pair(j.anchor) || !pair(j.targetAnchor)) fail("неверная точка крепления"); if (j.length !== undefined && !positive(j.length)) fail("длина шарнира > 0"); for (const k of ["stiffness", "damping"]) if (j[k] !== undefined && (!finite(j[k]) || j[k] < 0)) fail("неверный параметр пружины"); } if (c.camera?.mode === "2d") { if ( c.camera.bounds && (!Array.isArray(c.camera.bounds) || c.camera.bounds.length !== 4 || !c.camera.bounds.every(finite) || c.camera.bounds[0] > c.camera.bounds[2] || c.camera.bounds[1] > c.camera.bounds[3]) ) fail("границы камеры: [minX,minY,maxX,maxY]"); if (c.camera.orthoWidth !== undefined && !positive(c.camera.orthoWidth)) fail("ширина камеры > 0"); if ( c.camera.pixelsPerUnit !== undefined && !positive(c.camera.pixelsPerUnit) ) fail("PPU камеры > 0"); } } export function sliceImage( width: number, height: number, frameWidth: number, frameHeight: number, margin = 0, spacing = 0, ): SpriteFrame[] { if ( ![width, height, frameWidth, frameHeight].every((v) => integer(v, 1, 16384), ) || ![margin, spacing].every((v) => integer(v, 0, 16384)) ) fail("неверные размеры нарезки"); const frames: SpriteFrame[] = []; for ( let y = margin; y + frameHeight <= height - margin; y += frameHeight + spacing ) for ( let x = margin; x + frameWidth <= width - margin; x += frameWidth + spacing ) { if (frames.length >= 4096) fail("слишком много кадров"); frames.push({ name: String(frames.length), x, y, width: frameWidth, height: frameHeight, pivot: [0.5, 0.5], }); } if (!frames.length) fail("ни один кадр не помещается"); return frames; } export function frameAt( clip: { frames: number[]; fps: number; loop?: boolean }, seconds: number, ) { const i = Math.max(0, Math.floor(seconds * clip.fps)); return clip.frames[ clip.loop === false ? Math.min(i, clip.frames.length - 1) : i % clip.frames.length ]; } export function tileRectangles(cells: TileCell[]) { const left = new Set( cells.filter((c) => c.solid !== false).map((c) => `${c.x},${c.y}`), ), rects: { x: number; y: number; width: number; height: number }[] = []; for (const cell of [...cells].sort((a, b) => a.y - b.y || a.x - b.x)) { if (!left.has(`${cell.x},${cell.y}`)) continue; let width = 1, height = 1; while (left.has(`${cell.x + width},${cell.y}`)) width++; while ( Array.from({ length: width }, (_, i) => left.has(`${cell.x + i},${cell.y + height}`), ).every(Boolean) ) height++; for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) left.delete(`${cell.x + x},${cell.y + y}`); rects.push({ x: cell.x, y: cell.y, width, height }); } return rects; } export function paintTiles( cells: TileCell[], points: { x: number; y: number }[], frame: number | null, width: number, height: number, ) { const map = new Map(cells.map((c) => [`${c.x},${c.y}`, { ...c }])); for (const p of points) if (integer(p.x, 0, width - 1) && integer(p.y, 0, height - 1)) { const key = `${p.x},${p.y}`; if (frame === null) map.delete(key); else map.set(key, { ...p, frame }); } return [...map.values()]; } export function fillTiles( cells: TileCell[], x: number, y: number, frame: number | null, width: number, height: number, ) { const source = new Map(cells.map((c) => [`${c.x},${c.y}`, c.frame])), from = source.get(`${x},${y}`) ?? null; if (from === frame) return cells; const seen = new Set(), stack = [{ x, y }], points = []; while (stack.length) { const p = stack.pop()!, key = `${p.x},${p.y}`; if ( p.x < 0 || p.y < 0 || p.x >= width || p.y >= height || seen.has(key) || (source.get(key) ?? null) !== from ) continue; seen.add(key); points.push(p); if (points.length > 65536) fail("заливка превышает 65536 тайлов"); stack.push( { x: p.x - 1, y: p.y }, { x: p.x + 1, y: p.y }, { x: p.x, y: p.y - 1 }, { x: p.x, y: p.y + 1 }, ); } return paintTiles(cells, points, frame, width, height); }