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
+105 -16
View File
@@ -73,8 +73,14 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo
.length,
);
const prompts = await client.listPrompts();
assert.deepEqual(prompts.prompts.map((p) => p.name), ["create_scene"]);
const prompt = await client.getPrompt({ name: "create_scene", arguments: { theme: "architecture" } });
assert.deepEqual(
prompts.prompts.map((p) => p.name),
["create_scene"],
);
const prompt = await client.getPrompt({
name: "create_scene",
arguments: { theme: "architecture" },
});
assert.match(JSON.stringify(prompt.messages), /architecture/);
const invoke = async (name: string, args: any = {}) => {
const r = await client.callTool({ name, arguments: args });
@@ -185,18 +191,101 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo
}
});
test('MCP scene ZIP import replaces the scene atomically and Undo restores the prior project',async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-scene-'));
const s=await createService({projectDir:dir,port:0,blank:true});const client=new Client({name:'scene-test',version:'1'});
try{
await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${s.port}/mcp`),{requestInit:{headers:{authorization:'Bearer '+s.token}}}));
const original=structuredClone(s.service.store.project);
const {zipSync}=await import('fflate');const {sceneAnimationGlb}=await import('./fixtures.ts');
const archive=zipSync({'scene.glb':sceneAnimationGlb()});
const result=await client.callTool({name:'asset_import_glb',arguments:{expectedRevision:0,name:'scene.zip',base64:Buffer.from(archive).toString('base64'),asScene:true}});
assert.ok(!result.isError,JSON.stringify(result.content));
const p=s.service.store.project,n=p.scenes[0].entities[0];assert.equal(p.revision,1);assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
const undo=await client.callTool({name:'history_undo',arguments:{expectedRevision:1}});assert.ok(!undo.isError,JSON.stringify(undo.content));
assert.equal(s.service.store.project.id,original.id);assert.equal(s.service.store.project.assets.length,0);
}finally{await client.close();await s.close();await rm(dir,{recursive:true,force:true});}
test("MCP scene ZIP import replaces the scene atomically and Undo restores the prior project", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "forma-scene-"));
const s = await createService({ projectDir: dir, port: 0, blank: true });
const client = new Client({ name: "scene-test", version: "1" });
try {
await client.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${s.port}/mcp`),
{ requestInit: { headers: { authorization: "Bearer " + s.token } } },
),
);
const original = structuredClone(s.service.store.project);
const { zipSync } = await import("fflate");
const { sceneAnimationGlb } = await import("./fixtures.ts");
const archive = zipSync({ "scene.glb": sceneAnimationGlb() });
const result = await client.callTool({
name: "asset_import_glb",
arguments: {
expectedRevision: 0,
name: "scene.zip",
base64: Buffer.from(archive).toString("base64"),
asScene: true,
},
});
assert.ok(!result.isError, JSON.stringify(result.content));
const p = s.service.store.project,
n = p.scenes[0].entities[0];
assert.equal(p.revision, 1);
assert.equal(n.components.camera.mode, "imported");
assert.equal(n.components.animator.autoplay, "Scene");
const undo = await client.callTool({
name: "history_undo",
arguments: { expectedRevision: 1 },
});
assert.ok(!undo.isError, JSON.stringify(undo.content));
assert.equal(s.service.store.project.id, original.id);
assert.equal(s.service.store.project.assets.length, 0);
} finally {
await client.close();
await s.close();
await rm(dir, { recursive: true, force: true });
}
});
test("MCP imports image assets, configures XY scenes, saves portable sprites and rejects escaping paths", async () => {
const dir = await mkdtemp(path.join(os.tmpdir(), "forma-2d-"));
const s = await createService({ projectDir: dir, port: 0, blank: true }),
client = new Client({ name: "2d-test", version: "1" });
try {
await client.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${s.port}/mcp`),
{ requestInit: { headers: { authorization: "Bearer " + s.token } } },
),
);
const call = (name: string, args: any) =>
client.callTool({ name, arguments: args });
const created = await call("scene_create", {
name: "XY",
mode: "2d",
expectedRevision: s.service.store.project.revision,
});
assert.ok(!created.isError, JSON.stringify(created));
const { activeScene } = await import("../engine/schema.ts");
assert.equal(activeScene(s.service.store.project).mode, "2d");
const png =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLttAAAAABJRU5ErkJggg==";
const imported = await call("asset_import_image", {
name: "pixel.png",
base64: png,
instantiate: true,
expectedRevision: s.service.store.project.revision,
});
assert.ok(!imported.isError, JSON.stringify(imported));
const project = s.service.store.project;
assert.equal(project.assets[0].kind, "image");
assert.equal(
activeScene(project).entities[0].components.sprite.assetId,
project.assets[0].id,
);
const bad = await call("asset_import_image", {
name: "pixel.png",
path: "../escape.png",
expectedRevision: project.revision,
});
assert.ok(bad.isError);
assert.equal(s.service.store.project.revision, project.revision);
await call("project_save", {});
const restored = unpackProject(
new Uint8Array(await readFile(path.join(dir, "project.forma"))),
);
assert.deepEqual(restored, project);
} finally {
await client.close();
await s.close();
await rm(dir, { recursive: true, force: true });
}
});
+219 -35
View File
@@ -313,46 +313,230 @@ test(
},
);
test('changing a parent material or transform does not repaint child entities', async () => {
const r = runtime(), p = defaultProject(true);
const parent = entity('Parent', {mesh:{type:'box'}, material:{color:'#ff0000',alpha:.5}}, [0,0,0], 'parent');
const child = entity('Child', {mesh:{type:'box'}, material:{color:'#00ff00',alpha:1}}, [2,0,0], 'child');
child.parentId = parent.id; activeScene(p).entities = [parent,child];
test("changing a parent material or transform does not repaint child entities", async () => {
const r = runtime(),
p = defaultProject(true);
const parent = entity(
"Parent",
{ mesh: { type: "box" }, material: { color: "#ff0000", alpha: 0.5 } },
[0, 0, 0],
"parent",
);
const child = entity(
"Child",
{ mesh: { type: "box" }, material: { color: "#00ff00", alpha: 1 } },
[2, 0, 0],
"child",
);
child.parentId = parent.id;
activeScene(p).entities = [parent, child];
try {
await r.load(p);
parent.transform.position = [3,0,0]; parent.components.material.roughness = .1;
parent.transform.position = [3, 0, 0];
parent.components.material.roughness = 0.1;
await r.load(p);
const mat:any = r.nodes.get('child')!.getChildMeshes()[0].material;
assert.deepEqual(mat.albedoColor.asArray(), [0,1,0]); assert.equal(mat.alpha,1);
assert.equal((r.nodes.get('parent')!.getChildMeshes().find(m=>m.metadata.entityId==='parent')!.material as any).roughness,.1);
} finally { r.dispose(); }
const mat: any = r.nodes.get("child")!.getChildMeshes()[0].material;
assert.deepEqual(mat.albedoColor.asArray(), [0, 1, 0]);
assert.equal(mat.alpha, 1);
assert.equal(
(
r.nodes
.get("parent")!
.getChildMeshes()
.find((m) => m.metadata.entityId === "parent")!.material as any
).roughness,
0.1,
);
} finally {
r.dispose();
}
});
import { sceneAnimationGlb } from './fixtures.ts';
test('imported material, morph, camera and light animation targets are independent per entity', async () => {
const r=runtime(), p=defaultProject(true), bytes=sceneAnimationGlb();
p.assets=[{id:'scene',name:'scene.glb',kind:'model',uri:'data:model/gltf-binary;base64,'+bytes.toString('base64')}];
activeScene(p).entities=['a','b'].map(id=>entity(id,{mesh:{type:'model',assetId:'scene'},...(id==='a'?{camera:{mode:'imported'},animator:{autoplay:'Scene'}}:{})},[0,0,0],id));
import { sceneAnimationGlb } from "./fixtures.ts";
test("imported material, morph, camera and light animation targets are independent per entity", async () => {
const r = runtime(),
p = defaultProject(true),
bytes = sceneAnimationGlb();
p.assets = [
{
id: "scene",
name: "scene.glb",
kind: "model",
uri: "data:model/gltf-binary;base64," + bytes.toString("base64"),
},
];
activeScene(p).entities = ["a", "b"].map((id) =>
entity(
id,
{
mesh: { type: "model", assetId: "scene" },
...(id === "a"
? { camera: { mode: "imported" }, animator: { autoplay: "Scene" } }
: {}),
},
[0, 0, 0],
id,
),
);
try {
await r.play(p);
assert.ok(!r.logs.some(l=>l.level==='error'),JSON.stringify(r.logs));
const a=r.containers.get('a')!, b=r.containers.get('b')!;
assert.equal(r.scene.activeCamera,a.cameras[0]);
const group=r.animations.get('a')![0];
assert.ok(group.isStarted,'autoplay starts the imported timeline');
group.pause(); group.goToFrame(group.to);
const am:any=a.materials[0], bm:any=b.materials[0];
const close=(value:number,expected:number)=>assert.ok(Math.abs(value-expected)<.0001,`${value} != ${expected}`);
close(am.metallic,.9);close(am.roughness,.8);close(am.albedoColor.b,1);close(am.alpha,.5);
close(bm.metallic,.1);close(bm.roughness,.2);close(bm.albedoColor.r,1);
close(a.cameras[0].fov,1.1);close(b.cameras[0].fov,.7);
close(a.cameras[1].orthoLeft!,-4);close(a.cameras[1].orthoRight!,4);
close(a.cameras[1].orthoBottom!,-3);close(a.cameras[1].orthoTop!,3);
close(a.lights[0].intensity,20);close(b.lights[0].intensity,10);
close(a.morphTargetManagers[0].getTarget(0).influence,1);
close(b.morphTargetManagers[0].getTarget(0).influence,0);
activeScene(p).entities=activeScene(p).entities.filter(n=>n.id==='b');
await r.stop(p);await r.load(p);
assert.equal(r.containers.has('a'),false);assert.equal(r.containers.get('b')!.meshes[0].isDisposed(),false);
} finally {r.dispose();}
assert.ok(!r.logs.some((l) => l.level === "error"), JSON.stringify(r.logs));
const a = r.containers.get("a")!,
b = r.containers.get("b")!;
assert.equal(r.scene.activeCamera, a.cameras[0]);
const group = r.animations.get("a")![0];
assert.ok(group.isStarted, "autoplay starts the imported timeline");
group.pause();
group.goToFrame(group.to);
const am: any = a.materials[0],
bm: any = b.materials[0];
const close = (value: number, expected: number) =>
assert.ok(Math.abs(value - expected) < 0.0001, `${value} != ${expected}`);
close(am.metallic, 0.9);
close(am.roughness, 0.8);
close(am.albedoColor.b, 1);
close(am.alpha, 0.5);
close(bm.metallic, 0.1);
close(bm.roughness, 0.2);
close(bm.albedoColor.r, 1);
close(a.cameras[0].fov, 1.1);
close(b.cameras[0].fov, 0.7);
close(a.cameras[1].orthoLeft!, -4);
close(a.cameras[1].orthoRight!, 4);
close(a.cameras[1].orthoBottom!, -3);
close(a.cameras[1].orthoTop!, 3);
close(a.lights[0].intensity, 20);
close(b.lights[0].intensity, 10);
close(a.morphTargetManagers[0].getTarget(0).influence, 1);
close(b.morphTargetManagers[0].getTarget(0).influence, 0);
activeScene(p).entities = activeScene(p).entities.filter(
(n) => n.id === "b",
);
await r.stop(p);
await r.load(p);
assert.equal(r.containers.has("a"), false);
assert.equal(r.containers.get("b")!.meshes[0].isDisposed(), false);
} finally {
r.dispose();
}
});
test(
"Mixed 2D/3D scene, worker physics commands, animation and Stop restoration",
{ timeout: 15000 },
async () => {
const { project2D } = await import("../engine/template2d.ts");
const p = project2D(true),
r = runtime();
p.scripts.push({
id: "two_d_script",
name: "2D test",
fields: {},
source:
"({start(api){api.impulse2D([1,0]);},update(api,dt){api.state.ticks=(api.state.ticks||0)+1;api.patch(api.get().id,{components:{data:{ticks:api.state.ticks}}});},collision2d(api,event){api.patch(api.get().id,{components:{data:{collided:event.otherId}}})}})",
});
activeScene(p).entities.find(
(n) => n.id === "crate_2d",
)!.components.script = { scriptId: "two_d_script" };
activeScene(p).entities.push(
entity(
"3D fall",
{
mesh: { type: "box", size: [1, 1, 1] },
collider: { shape: "box", size: [1, 1, 1] },
rigidbody: { type: "dynamic" },
},
[0, 3, -6],
"three_d",
),
);
try {
await r.play(p);
assert.ok(r.physics2d);
assert.equal(r.scene.activeCamera!.mode, 1);
r.setInput({ x: 1, durationMs: 5000 });
const deadline = Date.now() + 4000;
while (Date.now() < deadline) {
const state = r.snapshot();
if (
state.entities.find((n) => n.id === "player_2d")!.transform
.position[0] > 4 &&
state.entities.find((n) => n.id === "three_d")!.transform
.position[1] < -1 &&
state.entities.find((n) => n.id === "crate_2d")!.components.data
?.collided
)
break;
await wait(50);
}
const snap = r.snapshot(),
hero = snap.entities.find((n) => n.id === "player_2d")!,
crate = snap.entities.find((n) => n.id === "crate_2d")!;
assert.ok(hero.transform.position[0] > 4, "builtin controller moved");
assert.equal(hero.transform.position[2], 0);
assert.ok(crate.components.data.ticks > 5, JSON.stringify(snap.logs));
assert.ok(
snap.entities.find((n) => n.id === "three_d")!.transform.position[1] <
0,
"3D body falls through 2D ground",
);
assert.equal(snap.physics.player_2d.dimension, 2);
assert.ok(
crate.components.data.collided,
"Worker receives 2D collision events",
);
assert.ok(
!snap.logs.some((l) => l.level === "error"),
JSON.stringify(snap.logs),
);
await r.stop(p);
assert.equal(r.physics2d, undefined);
assert.deepEqual(
r.state.find((n) => n.id === "player_2d")!.transform.position,
[3, 2, 0],
);
r.setView2D(true);
assert.equal(r.camera.mode, 1);
r.setView2D(false);
assert.equal(r.camera.mode, 0);
} finally {
r.dispose();
}
},
);
test("Spawning a 2D prefab creates its physics world when the scene initially had none", async () => {
const p = defaultProject(),
r = runtime();
p.assets = [
{
id: "prefab2d",
kind: "prefab",
name: "2D body",
entities: [
entity(
"Body",
{
sprite: {},
collider2d: { shape: "box", size: [1, 1] },
rigidbody2d: { type: "fixed" },
},
[0, 0, 0],
"body2d",
),
],
},
];
try {
await r.play(p);
assert.equal(r.physics2d, undefined);
await (r as any).spawn("prefab2d", [4, 3, -2]);
assert.equal(r.physics2d!.entries.size, 1);
const n = r.state[0];
assert.notEqual(n.id, "body2d");
assert.deepEqual(n.transform.position, [4, 3, -2]);
assert.ok(r.graphics2d.visuals.has(n.id));
} finally {
r.dispose();
}
});
+422
View File
@@ -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,
);
});