Add shared-scene 2D authoring, sprite animation and Rapier2D physics
CI / validate (push) Canceled after 0s
CI / validate (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,422 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user