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

423 lines
12 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import * as B from "@babylonjs/core";
import { Physics2D, loadRapier2D } from "../engine/physics2d.ts";
import { Graphics2D, spriteQuad } from "../engine/graphics2d.ts";
import {
sliceImage,
frameAt,
tileRectangles,
paintTiles,
fillTiles,
} from "../engine/two-d.ts";
import { imageAsset } from "../engine/image-import.ts";
import {
entity,
activeScene,
validateProject,
clone,
} from "../engine/schema.ts";
import { defaultProject } from "../engine/templates.ts";
import { ProjectStore } from "../engine/store.ts";
import { projectArchive, unpackProject } from "../engine/archive.ts";
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLttAAAAABJRU5ErkJggg==",
"base64",
);
const scene = (nodes: any[]) => {
const p = defaultProject();
p.assets = [];
p.scripts = [];
activeScene(p).entities = nodes;
activeScene(p).mode = "2d";
return p;
};
function node(
id: string,
type = "fixed",
pos: [number, number, number] = [0, 0, 0],
size = [1, 1],
extra: any = {},
) {
return entity(
id,
{
collider2d: { shape: "box", size },
rigidbody2d: { type, lockRotation: true },
...extra,
},
pos,
id,
);
}
async function setup(nodes: any[]) {
const engine = new B.NullEngine(),
s = new B.Scene(engine);
s.useRightHandedSystem = true;
const p = scene(nodes);
validateProject(p);
const physics = await Physics2D.create(p),
roots = new Map(nodes.map((n) => [n.id, new B.TransformNode(n.id, s)]));
for (const n of nodes) {
const r = roots.get(n.id)!;
r.position.fromArray(n.transform.position);
r.rotation.fromArray(n.transform.rotation);
r.scaling.fromArray(n.transform.scale);
if (n.parentId) r.parent = roots.get(n.parentId)!;
}
for (const n of nodes) physics.add(n, roots.get(n.id)!);
physics.connect();
return {
physics,
roots,
dispose() {
physics.dispose();
engine.dispose();
},
step(count: number, input: any = {}) {
physics.setInput(input);
for (let i = 0; i < count; i++) physics.step(1 / 60, new Map());
},
};
}
test("2D slicing, pivots, flipped UV, animation, tile strokes, fill and merged collision rectangles", () => {
const frames = sliceImage(34, 18, 16, 16, 1);
assert.equal(frames.length, 2);
assert.equal(frames[1].x, 17);
const q = spriteQuad(
{ ...frames[0], pivot: [0, 1] },
34,
18,
16,
undefined,
true,
);
assert.deepEqual(
q.positions.slice(0, 6).map((v) => v || 0),
[-1, 0, 0, 0, 0, 0],
);
assert.ok(q.uvs[0] > q.uvs[2]);
assert.equal(frameAt({ frames: [2, 3, 4], fps: 4 }, 0.8), 2);
assert.equal(frameAt({ frames: [2, 3, 4], fps: 4, loop: false }, 10), 4);
const cells = fillTiles([], 0, 0, 0, 4, 3);
assert.equal(cells.length, 12);
assert.deepEqual(tileRectangles(cells), [
{ x: 0, y: 0, width: 4, height: 3 },
]);
assert.equal(paintTiles(cells, [{ x: 0, y: 0 }], null, 4, 3).length, 11);
assert.throws(() => sliceImage(8, 8, 16, 16));
});
test("Image assets, frames and XY scene survive portable archives; invalid edits are atomic", async () => {
const a = imageAsset(png, "pixel.png"),
p = scene([
entity(
"sprite",
{ sprite: { assetId: a.id, frame: 0 } },
[2, 3, -5],
"sprite",
),
]);
p.assets = [a];
validateProject(p);
const packed = await projectArchive(p),
restored = unpackProject(packed);
assert.deepEqual(restored, p);
const store = new ProjectStore(p),
before = clone(store.project);
assert.throws(() =>
store.transaction({
commands: [{ op: "asset.delete", args: { id: a.id } }],
}),
);
assert.deepEqual(store.project, before);
assert.throws(
() =>
validateProject(
scene([
node("mixed", "dynamic", [0, 0, 0], [1, 1], {
rigidbody: { type: "dynamic" },
}),
]),
),
/смешивать/,
);
assert.throws(
() =>
validateProject(
scene([
node("bad", "dynamic", [0, 0, 0], [1, 1], {
collider2d: {
shape: "polygon",
points: [
[0, 0],
[1, 0],
[0.2, 0.2],
[1, 1],
[0, 1],
],
},
}),
]),
),
/выпуклым/,
);
});
test("Rapier2D falling dynamic body collides across visual Z, preserving Z; masks isolate bodies", async () => {
const floor = node("floor", "fixed", [0, -0.5, -8], [30, 1]),
box = node("box", "dynamic", [0, 4, 7]);
const f = await setup([floor, box]);
try {
f.step(180);
assert.ok(
Math.abs(box.transform.position[1] - 0.5) < 0.08,
String(box.transform.position),
);
assert.equal(box.transform.position[2], 7);
assert.ok(
f.physics.drainEvents().some((e) => e.entityId === "box" && e.started),
);
f.physics.impulse("box", [0, 5]);
f.step(10);
assert.ok(box.transform.position[1] > 1);
} finally {
f.dispose();
}
const isolated = node("isolated", "dynamic", [0, 2, 0], [1, 1], {
collider2d: { shape: "box", size: [1, 1], membership: 2, mask: 2 },
});
const f2 = await setup([clone(floor), isolated]);
try {
f2.step(120);
assert.ok(isolated.transform.position[1] < -5);
} finally {
f2.dispose();
}
});
test("Character2D walks, jumps, hits walls, and lands on one-way platforms from below", async () => {
const hero = node("hero", "kinematic", [0, 1, 4], [0.6, 1], {
character2d: {
mode: "platformer",
speed: 4,
jumpSpeed: 9,
gravity: 20,
autostep: 0,
},
}),
floor = node("floor", "fixed", [0, -0.5, 0], [30, 1]),
wall = node("wall", "fixed", [2, 3, 0], [0.4, 6]),
platform = node("platform", "fixed", [0, 1.6, 0], [2, 0.2], {
collider2d: { shape: "box", size: [2, 0.2], oneWay: true },
});
const f = await setup([floor, wall, platform, hero]);
try {
f.step(60);
assert.ok(f.physics.snapshot().hero.grounded);
assert.ok(
f.physics
.drainEvents()
.some(
(e) =>
e.entityId === "hero" &&
e.otherId === "floor" &&
e.started &&
!e.sensor,
),
);
f.step(25, { jump: true });
assert.ok(
hero.transform.position[1] > 2.4,
String(hero.transform.position),
);
f.step(80);
assert.ok(
Math.abs(hero.transform.position[1] - 2.2) < 0.08,
String(hero.transform.position),
);
assert.ok(f.physics.snapshot().hero.grounded);
f.step(80, { x: 1 });
assert.ok(
hero.transform.position[0] < 1.55 && hero.transform.position[0] > 1.4,
String(hero.transform.position),
);
assert.equal(hero.transform.position[2], 4);
} finally {
f.dispose();
}
});
test("Top down movement, trigger enter/exit, disabled colliders, and parent-local teleport", async () => {
const hero = node("hero", "kinematic", [0, 0, 3], [0.6, 0.6], {
character2d: { mode: "topDown", speed: 3 },
}),
sensor = node("sensor", "fixed", [2, 0, -10], [1, 2], {
collider2d: { shape: "box", size: [1, 2], sensor: true },
});
const f = await setup([sensor, hero]);
try {
f.step(100, { x: 1 });
assert.ok(hero.transform.position[0] > 4.8);
assert.ok(Math.abs(hero.transform.position[1]) < 0.001);
const events = f.physics.drainEvents().filter((e) => e.entityId === "hero");
assert.ok(events.some((e) => e.sensor && e.started));
assert.ok(events.some((e) => e.sensor && !e.started));
f.roots.get("sensor")!.setEnabled(false);
f.physics.setEnabled("sensor");
assert.equal(f.physics.snapshot().sensor.enabled, false);
} finally {
f.dispose();
}
const parent = entity("parent", {}, [10, 4, 2], "parent");
parent.transform.rotation = [0, 0, Math.PI / 2];
parent.transform.scale = [2, 2, 1];
const child = node("child", "kinematic", [1, 0, 5]);
child.parentId = parent.id;
const f2 = await setup([parent, child]);
try {
f2.physics.teleport("child", [2, 0, 5]);
f2.step(1);
assert.ok(Math.abs(child.transform.position[0] - 2) < 0.001);
assert.equal(child.transform.position[2], 5);
assert.ok(
Math.abs(f2.physics.entries.get("child")!.body.translation().y - 8) <
0.001,
);
assert.throws(() => f2.physics.teleport("child", [NaN, 0, 0]));
} finally {
f2.dispose();
}
});
test("Tilemap produces merged colliders and 2D joints connect real bodies", async () => {
const tile = entity(
"map",
{
tilemap: {
assetId: "",
width: 4,
height: 2,
tileSize: [1, 1],
cells: fillTiles([], 0, 0, 0, 4, 2),
collisions: true,
},
},
[0, -2, 0],
"map",
),
ball = node("ball", "dynamic", [1, 3, 0]);
const f = await setup([tile, ball]);
try {
assert.equal(f.physics.entries.get("map")!.colliders.length, 1);
f.step(180);
assert.ok(Math.abs(ball.transform.position[1] - 0.5) < 0.08);
} finally {
f.dispose();
}
for (const type of ["fixed", "revolute", "rope", "spring"]) {
const anchor = node("anchor", "fixed", [0, 3, 0]),
weight = node("weight", "dynamic", [0, 1, 0], [0.4, 0.4], {
joint2d: {
type,
targetId: "anchor",
anchor: [0, 1],
targetAnchor: [0, -1],
length: 1,
stiffness: 50,
damping: 5,
},
});
const j = await setup([anchor, weight]);
try {
assert.equal(j.physics.joints.size, 1);
j.step(180);
assert.ok(weight.transform.position[1] > -1);
} finally {
j.dispose();
}
}
});
test("Sprite animation pause, one-shot hold, sorting and Tilemap batched geometry", () => {
const e = new B.NullEngine(),
s = new B.Scene(e),
g = new Graphics2D(s),
p = scene([]),
a = imageAsset(png, "pixel.png");
a.image!.frames = [
...a.image!.frames,
...a.image!.frames.map((f) => ({ ...f, name: "second" })),
];
p.assets = [a];
const n = entity(
"sprite",
{
sprite: { assetId: a.id, layer: 2, order: 3 },
spriteAnimator: {
autoplay: "idle",
clips: [{ name: "idle", frames: [0, 1], fps: 4, loop: false }],
},
},
[0, 0, 0],
"sprite",
);
try {
g.create(n, p, new B.TransformNode("root", s));
g.start();
g.tick(0.3, true, false);
assert.equal(g.snapshot().sprite.frame, 1);
g.tick(1, true, true);
assert.equal(g.snapshot().sprite.playing, true);
g.tick(1, true, false);
g.tick(1, true, false);
assert.equal(g.snapshot().sprite.frame, 1);
assert.equal(g.snapshot().sprite.playing, false);
g.update(n, true);
assert.equal(
g.snapshot().sprite.frame,
1,
"transform or data patches preserve a completed animation frame",
);
assert.equal(g.visuals.get("sprite")!.mesh.alphaIndex, 2000030);
const t = entity(
"map",
{
tilemap: {
assetId: "",
width: 2,
height: 2,
tileSize: [1, 1],
cells: fillTiles([], 0, 0, 0, 2, 2),
},
},
[0, 0, 0],
"map",
);
g.create(t, p, new B.TransformNode("map-root", s));
assert.equal(g.visuals.get("map")!.mesh.getTotalVertices(), 16);
} finally {
g.dispose();
e.dispose();
}
});
test("2D prefab duplication remaps internal joint targets", () => {
const root = entity("root", {}, [0, 0, 0], "root"),
a = node("a", "fixed"),
b = node("b", "dynamic", [0, -2, 0], [1, 1], {
joint2d: {
type: "revolute",
targetId: "a",
anchor: [0, 1],
targetAnchor: [0, -1],
},
});
a.parentId = b.parentId = root.id;
const store = new ProjectStore(scene([root, a, b]));
store.transaction({
commands: [{ op: "node.duplicate", args: { id: "root" } }],
});
const nodes = activeScene(store.project).entities,
newB = nodes.find((n) => n.id !== "b" && n.components.joint2d)!;
assert.notEqual(newB.components.joint2d.targetId, "a");
assert.equal(
nodes.find((n) => n.id === newB.components.joint2d.targetId)!.parentId,
newB.parentId,
);
});