Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s

This commit is contained in:
emil28092005
2026-09-12 04:58:59 +03:00
parent 6f497d1977
commit 3afac743a4
32 changed files with 4999 additions and 270 deletions
+431
View File
@@ -0,0 +1,431 @@
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);
}