543 lines
16 KiB
TypeScript
543 lines
16 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { Worker as NodeWorker } from "node:worker_threads";
|
|
import { NullEngine } from "@babylonjs/core";
|
|
import { FormaRuntime } from "../engine/runtime.ts";
|
|
import { defaultProject } from "../engine/templates.ts";
|
|
import { entity, activeScene } from "../engine/schema.ts";
|
|
import { triangleAsset } from "./fixtures.ts";
|
|
function worker(source: string) {
|
|
const w = new NodeWorker(
|
|
'const {parentPort}=require("node:worker_threads");global.self=global;global.postMessage=m=>parentPort.postMessage(m);' +
|
|
source +
|
|
';parentPort.on("message",data=>self.onmessage({data}));',
|
|
{ eval: true },
|
|
);
|
|
const adapter: any = {
|
|
onmessage: null,
|
|
onerror: null,
|
|
postMessage: (m: any) => w.postMessage(m),
|
|
terminate: () => void w.terminate(),
|
|
};
|
|
w.on("message", (m) => adapter.onmessage?.({ data: m }));
|
|
w.on("error", (e) => adapter.onerror?.({ message: e.message }));
|
|
return adapter as Worker;
|
|
}
|
|
function runtime() {
|
|
const engine = new NullEngine({
|
|
renderWidth: 800,
|
|
renderHeight: 600,
|
|
textureSize: 512,
|
|
deterministicLockstep: true,
|
|
lockstepMaxSteps: 4,
|
|
});
|
|
return new FormaRuntime(
|
|
{} as HTMLCanvasElement,
|
|
{},
|
|
{
|
|
engine,
|
|
headless: true,
|
|
createWorker: worker,
|
|
readAsset: async (uri) =>
|
|
new Uint8Array(Buffer.from(uri.slice(uri.indexOf(",") + 1), "base64")),
|
|
},
|
|
);
|
|
}
|
|
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
test(
|
|
"Babylon skeletal import, worker movement, Rapier collision and Stop restoration",
|
|
{ timeout: 15000 },
|
|
async () => {
|
|
const r = runtime(),
|
|
p = defaultProject();
|
|
p.assets = [triangleAsset(true)];
|
|
p.scripts = [
|
|
{
|
|
id: "move_fixture",
|
|
name: "Move fixture",
|
|
fields: { speed: { type: "number", default: 4 } },
|
|
source:
|
|
'({start(api){api.state.ticks=0;api.animate("Translate")},update(api,dt){api.state.ticks++;api.move([api.input.x*api.params.speed*dt,0,0]);api.patch(api.get().id,{components:{data:{ticks:api.state.ticks}}})}})',
|
|
},
|
|
];
|
|
const moving = entity(
|
|
"Moving triangle",
|
|
{
|
|
mesh: { type: "model", assetId: "asset_triangle" },
|
|
collider: {
|
|
shape: "capsule",
|
|
radius: 0.3,
|
|
height: 1.8,
|
|
offset: [0, 0.9, 0],
|
|
},
|
|
rigidbody: { type: "kinematic" },
|
|
script: { scriptId: "move_fixture" },
|
|
data: { ticks: 0 },
|
|
},
|
|
[0, 0.06, 0],
|
|
"moving",
|
|
);
|
|
activeScene(p).entities = [
|
|
entity(
|
|
"Floor",
|
|
{
|
|
mesh: { type: "box", size: [12, 0.5, 12] },
|
|
collider: { shape: "box", size: [12, 0.5, 12] },
|
|
rigidbody: { type: "fixed" },
|
|
},
|
|
[0, -0.25, 0],
|
|
"floor",
|
|
),
|
|
entity(
|
|
"Wall",
|
|
{
|
|
mesh: { type: "box", size: [0.4, 3, 12] },
|
|
collider: { shape: "box", size: [0.4, 3, 12] },
|
|
rigidbody: { type: "fixed" },
|
|
},
|
|
[2, 1.5, 0],
|
|
"wall",
|
|
),
|
|
moving,
|
|
entity(
|
|
"Camera",
|
|
{ camera: { targetId: "moving", offset: [0, 13, -10], fov: 0.72 } },
|
|
[0, 13, -10],
|
|
"camera",
|
|
),
|
|
];
|
|
try {
|
|
await r.play(p);
|
|
assert.equal(r.importInfo.get("asset_triangle").skeletons, 1);
|
|
assert.deepEqual(r.importInfo.get("asset_triangle").clips, ["Translate"]);
|
|
assert.equal(r.animations.get("moving")!.length, 1);
|
|
r.setInput({ x: 1, durationMs: 1400 });
|
|
await wait(1500);
|
|
const snap = r.snapshot();
|
|
const current = snap.entities.find((n) => n.id === "moving")!;
|
|
assert.ok(current.components.data.ticks > 10, JSON.stringify(snap.logs));
|
|
assert.ok(
|
|
snap.animations.moving.some(
|
|
(g) => g.name === "Translate" && g.frame !== null,
|
|
),
|
|
);
|
|
assert.ok(
|
|
!snap.logs.some((l) => l.level === "error"),
|
|
JSON.stringify(snap.logs),
|
|
);
|
|
const pos = current.transform.position;
|
|
assert.ok(
|
|
pos[0] > 1.1 && pos[0] < 1.55,
|
|
"Kinematic body stops at the wall: " + pos,
|
|
);
|
|
assert.ok(
|
|
pos[1] > -0.2 && pos[1] < 0.3,
|
|
"Kinematic body remains on floor: " + pos,
|
|
);
|
|
await r.stop(p);
|
|
assert.deepEqual(
|
|
r.state.find((n) => n.id === "moving")!.transform.position,
|
|
moving.transform.position,
|
|
);
|
|
assert.equal(
|
|
r.state.find((n) => n.id === "moving")!.components.data.ticks,
|
|
0,
|
|
);
|
|
assert.equal(r.playing, false);
|
|
} finally {
|
|
r.dispose();
|
|
}
|
|
},
|
|
);
|
|
test(
|
|
"parent rebuild preserves children; queued model replacement uses fresh bytes",
|
|
{ timeout: 15000 },
|
|
async () => {
|
|
const r = runtime(),
|
|
p = defaultProject();
|
|
p.assets = [triangleAsset(true)];
|
|
const parent = entity("Parent", {}, [0, 0, 0], "parent"),
|
|
child = entity(
|
|
"Child",
|
|
{ mesh: { type: "box", size: [1, 1, 1] } },
|
|
[1, 0, 0],
|
|
"child",
|
|
);
|
|
child.parentId = parent.id;
|
|
activeScene(p).entities = [parent, child];
|
|
try {
|
|
await r.load(p);
|
|
const childNode = r.nodes.get("child")!;
|
|
parent.components.mesh = { type: "sphere", size: [1, 1, 1] };
|
|
await r.load(p);
|
|
assert.equal(childNode.isDisposed(), false);
|
|
assert.equal(childNode.parent, r.nodes.get("parent"));
|
|
const n = entity(
|
|
"Model",
|
|
{ mesh: { type: "model", assetId: "asset_triangle" } },
|
|
[0, 0, 0],
|
|
"model",
|
|
);
|
|
activeScene(p).entities.push(n);
|
|
await r.load(p);
|
|
assert.equal(r.animations.get("model")!.length, 1);
|
|
p.assets[0] = triangleAsset();
|
|
await r.load(p);
|
|
assert.equal(r.animations.get("model")!.length, 0);
|
|
assert.ok(
|
|
!r.logs.some((l) => l.level === "error"),
|
|
JSON.stringify(r.logs),
|
|
);
|
|
} finally {
|
|
r.dispose();
|
|
}
|
|
},
|
|
);
|
|
test(
|
|
"runaway project script is terminated while runtime remains recoverable",
|
|
{ timeout: 10000 },
|
|
async () => {
|
|
const r = runtime(),
|
|
p = defaultProject(true);
|
|
p.scripts.push({
|
|
id: "bad_loop",
|
|
name: "Bad loop fixture",
|
|
source: "({update(){while(true){}}})",
|
|
fields: {},
|
|
});
|
|
activeScene(p).entities = [
|
|
entity("Loop", { script: { scriptId: "bad_loop" } }, [0, 0, 0], "loop"),
|
|
];
|
|
try {
|
|
await r.play(p);
|
|
await wait(2100);
|
|
assert.ok(r.logs.some((l) => l.message.includes("1500")));
|
|
await r.stop(p);
|
|
assert.equal(r.playing, false);
|
|
assert.equal(r.state.length, 1);
|
|
} finally {
|
|
r.dispose();
|
|
}
|
|
},
|
|
);
|
|
|
|
test(
|
|
"fixed orthographic camera, alpha events and legacy projection restore",
|
|
{ timeout: 10000 },
|
|
async () => {
|
|
const r = runtime(),
|
|
p = defaultProject(true);
|
|
p.settings.rendering = { toneMapping: false };
|
|
p.scripts = [
|
|
{
|
|
id: "fade",
|
|
name: "Fade",
|
|
fields: {},
|
|
source:
|
|
'({start(api){api.patch("visual",{components:{material:{alpha:.3,unlit:true}}})}})',
|
|
},
|
|
];
|
|
activeScene(p).entities = [
|
|
entity(
|
|
"Camera",
|
|
{
|
|
camera: {
|
|
mode: "fixed",
|
|
projection: "orthographic",
|
|
orthoWidth: 24,
|
|
aspect: 16 / 9,
|
|
lookAt: [0, 0, 0],
|
|
},
|
|
},
|
|
[10, 20, 30],
|
|
"camera",
|
|
),
|
|
entity(
|
|
"Visual",
|
|
{
|
|
mesh: { type: "box", size: [1, 1, 1] },
|
|
material: { color: "#ff8800", alpha: 1 },
|
|
script: { scriptId: "fade" },
|
|
},
|
|
[0, 0, 0],
|
|
"visual",
|
|
),
|
|
];
|
|
try {
|
|
await r.play(p);
|
|
for (
|
|
let i = 0;
|
|
i < 60 &&
|
|
r.state.find((n) => n.id === "visual")?.components.material.alpha !==
|
|
0.3;
|
|
i++
|
|
)
|
|
await wait(50);
|
|
assert.equal(r.gameCamera.mode, 1);
|
|
assert.deepEqual(r.gameCamera.position.asArray(), [10, 20, 30]);
|
|
assert.equal(r.gameCamera.orthoLeft, -12);
|
|
assert.equal(r.gameCamera.orthoTop, 6.75);
|
|
assert.equal(r.gameCamera.viewport.height, 0.75);
|
|
assert.equal(
|
|
r.scene.imageProcessingConfiguration.toneMappingEnabled,
|
|
false,
|
|
);
|
|
const mat: any = r.nodes.get("visual")!.getChildMeshes()[0].material;
|
|
assert.equal(mat.alpha, 0.3);
|
|
assert.equal(mat.unlit, true);
|
|
await r.stop(p);
|
|
assert.equal(
|
|
r.state.find((n) => n.id === "visual")!.components.material.alpha,
|
|
1,
|
|
);
|
|
const legacy = defaultProject(true);
|
|
activeScene(legacy).entities = [
|
|
entity(
|
|
"Camera",
|
|
{ camera: { offset: [0, 13, -10] } },
|
|
[0, 13, -10],
|
|
"camera",
|
|
),
|
|
];
|
|
await r.play(legacy);
|
|
await wait(50);
|
|
assert.equal(r.gameCamera.mode, 0);
|
|
assert.equal(r.gameCamera.viewport.height, 1);
|
|
assert.equal(
|
|
r.scene.imageProcessingConfiguration.toneMappingEnabled,
|
|
true,
|
|
);
|
|
} finally {
|
|
r.dispose();
|
|
}
|
|
},
|
|
);
|
|
|
|
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 = 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,
|
|
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,
|
|
),
|
|
);
|
|
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) < 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();
|
|
}
|
|
});
|