Publish Forma Engine 0.3.0 source with documentation and CI
This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user