423 lines
12 KiB
TypeScript
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,
|
|
);
|
|
});
|