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

432 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("маска: 065535");
if (col.shape === "polygon") {
if (
!Array.isArray(col.points) ||
col.points.length < 3 ||
col.points.length > 64 ||
!col.points.every(pair)
)
fail("полигон: 364 точки");
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);
}