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, ); });