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:
+105
-16
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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