432 lines
15 KiB
TypeScript
432 lines
15 KiB
TypeScript
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<string>();
|
||
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<string>(),
|
||
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);
|
||
}
|