400 lines
18 KiB
JavaScript
400 lines
18 KiB
JavaScript
import { edge, node } from '../lib/builders.mjs';
|
||
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/** Kicks its owner in On start, with the scene's gravity switched off first: it glides at 100 per second. */
|
||
const KICK = 200;
|
||
const KICKED_MASS = 2;
|
||
const kickGraph = {
|
||
nodes: [
|
||
node('pk-start', 'OnStart', 0, 0),
|
||
node('pk-gravity', 'SetGravity', 300, 0, { x: 0, y: 0 }),
|
||
node('pk-owner', 'Owner', 300, 160),
|
||
node('pk-kick', 'ApplyImpulse', 600, 0, { x: KICK, y: 0 }),
|
||
],
|
||
edges: [
|
||
edge('pk-e1', 'pk-start', 'exec', 'pk-gravity', 'exec'),
|
||
edge('pk-e2', 'pk-gravity', 'exec', 'pk-kick', 'exec'),
|
||
edge('pk-e3', 'pk-owner', 'object', 'pk-kick', 'object'),
|
||
],
|
||
};
|
||
|
||
/** Walks its owner right at 120 per second with "Collide with solids" on. */
|
||
const WALK = 120;
|
||
const walkGraph = {
|
||
nodes: [
|
||
node('pw-update', 'OnUpdate', 0, 0),
|
||
node('pw-owner', 'Owner', 0, 160),
|
||
node('pw-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }),
|
||
],
|
||
edges: [
|
||
edge('pw-e1', 'pw-update', 'exec', 'pw-move', 'exec'),
|
||
edge('pw-e2', 'pw-update', 'dt', 'pw-move', 'dt'),
|
||
edge('pw-e3', 'pw-owner', 'object', 'pw-move', 'object'),
|
||
],
|
||
};
|
||
|
||
/**
|
||
* Rolls its owner right at 120 per second with "Collide with solids" on, and logs Move's normal
|
||
* (x, then y) on every frame that hits something.
|
||
*/
|
||
const BALL_R = 20;
|
||
const CORNER_DROP = 12;
|
||
const rollGraph = {
|
||
nodes: [
|
||
node('pr-update', 'OnUpdate', 0, 0),
|
||
node('pr-owner', 'Owner', 0, 160),
|
||
node('pr-move', 'Move', 300, 0, { dx: 1, scale: WALK, collide: true }),
|
||
node('pr-hit', 'Branch', 600, 0),
|
||
node('pr-log-x', 'Log', 850, 0),
|
||
node('pr-log-y', 'Log', 1100, 0),
|
||
],
|
||
edges: [
|
||
edge('pr-e1', 'pr-update', 'exec', 'pr-move', 'exec'),
|
||
edge('pr-e2', 'pr-update', 'dt', 'pr-move', 'dt'),
|
||
edge('pr-e3', 'pr-owner', 'object', 'pr-move', 'object'),
|
||
edge('pr-e4', 'pr-move', 'exec', 'pr-hit', 'exec'),
|
||
edge('pr-e5', 'pr-move', 'hit', 'pr-hit', 'condition'),
|
||
edge('pr-e6', 'pr-hit', 'then', 'pr-log-x', 'exec'),
|
||
edge('pr-e7', 'pr-move', 'normalX', 'pr-log-x', 'message'),
|
||
edge('pr-e8', 'pr-log-x', 'exec', 'pr-log-y', 'exec'),
|
||
edge('pr-e9', 'pr-move', 'normalY', 'pr-log-y', 'message'),
|
||
],
|
||
};
|
||
|
||
/**
|
||
* A point of a panel (`panel` and `point` are hook expressions), scrolled into view first: a
|
||
* collider's material rows sit below the fold of the inspector, and after the earlier steps the
|
||
* hierarchy is long enough to hide this step's objects. A click beside the panel would land on
|
||
* whatever is drawn there instead.
|
||
*/
|
||
async function inView(t, panel, point) {
|
||
for (let i = 0; i < 15; i++) {
|
||
const rect = await t.json(panel);
|
||
const at = await t.json(point);
|
||
if (!rect) return null;
|
||
if (at && at.y > rect.y + 8 && at.y < rect.y + rect.h - 8) return at;
|
||
await t.wheel(rect.x + rect.w / 2, rect.y + rect.h / 2, at && at.y < rect.y ? -200 : 200);
|
||
await t.settle();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The physics engine (Rapier) loads in the editor, in the background after the first project is
|
||
* open, from a chunk of its own – the editor bundle does not carry it. A played scene simulates its
|
||
* rigidbodies on it (#146).
|
||
*/
|
||
export async function physics(t) {
|
||
const { check, json, waitUntil, consoleLog, evaluate, mutate, select, click, clickText, press, typeText } =
|
||
t;
|
||
const { has, object, asset, objects, state, settle, sleep, play, stop, shot } = t;
|
||
|
||
await waitUntil(`['ready', 'failed'].includes(__gm.physics().state)`, { timeout: 15000 });
|
||
const status = await json('__gm.physics()');
|
||
check(
|
||
'Rapier loads in the editor without Play',
|
||
status.state === 'ready' && status.version === '0.20.0',
|
||
JSON.stringify(status),
|
||
);
|
||
const scripts = await json(
|
||
`performance.getEntriesByType('resource').map((e) => e.name).filter((n) => n.endsWith('.js')).map((n) => n.split('/').pop())`,
|
||
);
|
||
check(
|
||
'it comes from a chunk of its own',
|
||
scripts.some((name) => /^rapier-.+\.js$/.test(name)),
|
||
JSON.stringify(scripts),
|
||
);
|
||
check(
|
||
'loading it writes nothing to the console',
|
||
!consoleLog.some((line) => /\[physics\]|rapier/i.test(line)),
|
||
consoleLog.filter((line) => /\[physics\]|rapier/i.test(line)).join(' | '),
|
||
);
|
||
|
||
// 41b. A crate and a ball above a ground on a lane of their own, each with a collider and no
|
||
// rigidbody yet. The rigidbodies come from the hierarchy menu, the ball's bounciness is typed
|
||
// into its collider's field, and the played scene is sampled frame by frame.
|
||
const Y = LANES.physics;
|
||
const REST = Y + 200 - 20 - 20; // the ground's top face minus half of a 40-high body
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const assets = __gm.state.project.assets;
|
||
${MAKE_OBJECT}
|
||
const collider = (id, props) =>
|
||
assets.push({ id, type: 'collider', name: id, props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false, ...props } });
|
||
collider('phys-col-ground', { width: 600, height: 40 });
|
||
collider('phys-col-crate', {});
|
||
collider('phys-col-ball', { shape: 'circle' });
|
||
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
|
||
const o = make(id, assetId, parentId, y, name);
|
||
o.position.x = x;
|
||
o.overrides = overrides;
|
||
scene.objects.push(o);
|
||
};
|
||
put('phys-ground', 'crate', '', 0, ${Y + 200}, 'Physics ground', { width: 600, height: 40 });
|
||
put('phys-crate', 'crate', '', -100, ${Y}, 'Physics crate', { width: 40, height: 40 });
|
||
put('phys-ball', 'crate', '', 100, ${Y}, 'Physics ball', { width: 40, height: 40 });
|
||
put('phys-ground-col', 'phys-col-ground', 'phys-ground', 0, 0);
|
||
put('phys-crate-col', 'phys-col-crate', 'phys-crate', 0, 0);
|
||
put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0);
|
||
__gm.state.select(null)`);
|
||
const selectObject = (id) => select({ kind: 'object', holderId: 'scene1', objectId: id, objectIds: [id] });
|
||
const rigidbodyFromMenu = async (id) => {
|
||
await selectObject(id);
|
||
const row = await inView(t, '__gm.leftColumn().objects', `__gm.hierarchyRow(${JSON.stringify(id)})`);
|
||
const before = (await objects()).length;
|
||
await click(row.x, row.y, 'right');
|
||
await clickText('Add rigidbody');
|
||
await press('Escape');
|
||
const s = await state();
|
||
const selected = s.selection?.kind === 'object' ? await object(s.selection.objectId) : null;
|
||
return { added: (await objects()).length - before, selected };
|
||
};
|
||
const crate = await rigidbodyFromMenu('phys-crate');
|
||
const crateBody = crate.selected;
|
||
check(
|
||
'Add rigidbody puts a dynamic rigidbody below the object and shows its settings',
|
||
crate.added === 1 &&
|
||
crateBody?.parentId === 'phys-crate' &&
|
||
(await asset(crateBody.assetId))?.type === 'rigidbody' &&
|
||
(await has('Body type')) &&
|
||
(await has('Dynamic')),
|
||
JSON.stringify(crate),
|
||
);
|
||
check(
|
||
'the rigidbody is drawn as an icon in the overlay',
|
||
crateBody && (await evaluate(`__gm.layerOf('${crateBody.id}')`)) === 'overlay',
|
||
);
|
||
const again = await rigidbodyFromMenu('phys-crate');
|
||
check('the menu offers no second rigidbody', again.added === 0, JSON.stringify(again));
|
||
const ballBody = (await rigidbodyFromMenu('phys-ball')).selected;
|
||
await evaluate(`__gm.lookAt(0, ${Y + 100})`);
|
||
await settle();
|
||
await shot('41-rigidbody');
|
||
|
||
await selectObject('phys-ball-col');
|
||
const field = await inView(t, '__gm.inspectorRect()', "__gm.inspectorField('Bounciness')");
|
||
if (field) {
|
||
await click(field.x, field.y);
|
||
await press('a', { ctrl: true });
|
||
await typeText('0.8');
|
||
await press('Enter');
|
||
}
|
||
await settle();
|
||
check(
|
||
"the collider's Bounciness field takes 0.8",
|
||
(await object('phys-ball-col'))?.overrides?.bounciness === 0.8,
|
||
JSON.stringify({ field, overrides: (await object('phys-ball-col'))?.overrides }),
|
||
);
|
||
|
||
await select(null);
|
||
await play();
|
||
await evaluate(`(() => {
|
||
const out = (window.__physicsSamples = []);
|
||
const start = performance.now();
|
||
const sample = () => {
|
||
const w = __gm.world();
|
||
if (!w) return;
|
||
const y = (id) => w.entities.find((e) => e.objectId === id)?.worldY;
|
||
out.push({ t: performance.now() - start, crate: y('phys-crate'), ball: y('phys-ball') });
|
||
if (performance.now() - start < 3000) requestAnimationFrame(sample);
|
||
};
|
||
requestAnimationFrame(sample);
|
||
})()`);
|
||
await sleep(3300);
|
||
const samples = await json('window.__physicsSamples');
|
||
await stop();
|
||
|
||
const last = samples.slice(-20).map((s) => s.crate);
|
||
check(
|
||
'the crate falls onto the ground and stays there',
|
||
samples.length > 100 && samples[0].crate < REST - 50 && last.every((y) => Math.abs(y - REST) < 1),
|
||
JSON.stringify({ frames: samples.length, first: samples[0], last: last.slice(-3), rest: REST }),
|
||
);
|
||
// The heights the ball climbs back to: every turn from rising to falling after the first landing.
|
||
// Turning points, not a threshold at the ground: the ball lands at ~9 px per frame, and whether a
|
||
// drawn frame comes within a pixel of the landing depends on where it falls between two steps.
|
||
const peaks = [];
|
||
let landed = false;
|
||
for (let i = 1; i < samples.length - 1; i++) {
|
||
const [before, y, after] = [samples[i - 1].ball, samples[i].ball, samples[i + 1].ball];
|
||
if (y >= before && y > after) landed = true;
|
||
else if (landed && y <= before && y < after && REST - y > 2) peaks.push(REST - y);
|
||
}
|
||
const drop = REST - Y;
|
||
check(
|
||
'the ball bounces back up and every bounce is lower than the one before',
|
||
peaks.length >= 2 &&
|
||
peaks[0] / drop > 0.4 &&
|
||
peaks[0] / drop < 0.8 &&
|
||
peaks.every((h, i) => i === 0 || h < peaks[i - 1]),
|
||
JSON.stringify({ drop, peaks: peaks.map((h) => Math.round(h * 10) / 10), frames: samples.length }),
|
||
);
|
||
check(
|
||
'playing left the scene as it was',
|
||
(await object('phys-crate'))?.position.y === Y && (await object('phys-ball'))?.position.y === Y,
|
||
);
|
||
|
||
const bodies = [crateBody?.id, ballBody?.id].filter(Boolean);
|
||
await mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
const gone = new Set(${JSON.stringify(bodies)});
|
||
const assets = new Set(scene.objects.filter((o) => gone.has(o.id)).map((o) => o.assetId));
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-') && !gone.has(o.id));
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-col-') && !assets.has(a.id));
|
||
__gm.state.select(null)`);
|
||
|
||
// 41c. The graph drives the physics (#147): a crate kicked in On start with gravity switched off
|
||
// by Set gravity, and a walker whose Move with "Collide with solids" runs into a free crate.
|
||
const hintsBefore = consoleLog.filter((line) => line.includes('[physics]')).length;
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const p = __gm.state.project;
|
||
const assets = p.assets;
|
||
${MAKE_OBJECT}
|
||
const behavior = (id, name, graph) => {
|
||
const asset = structuredClone(assets.find((a) => a.id === 'mover'));
|
||
Object.assign(asset, { id, name, props: { enabled: true, runInEditor: false }, variables: [], graph });
|
||
assets.push(asset);
|
||
};
|
||
behavior('phys-kick', 'Physics kick', ${JSON.stringify(kickGraph)});
|
||
behavior('phys-walk', 'Physics walk', ${JSON.stringify(walkGraph)});
|
||
assets.push({ id: 'phys-col-box', type: 'collider', name: 'phys-col-box', props: { shape: 'box', width: 40, height: 40, radius: 20, isTrigger: false } });
|
||
assets.push({ id: 'phys-rb-kicked', type: 'rigidbody', name: 'phys-rb-kicked', props: { bodyType: 'dynamic', mass: ${KICKED_MASS} } });
|
||
assets.push({ id: 'phys-rb-free', type: 'rigidbody', name: 'phys-rb-free', props: { bodyType: 'dynamic', mass: 0 } });
|
||
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
|
||
const o = make(id, assetId, parentId, y, name);
|
||
o.position.x = x;
|
||
o.overrides = overrides;
|
||
scene.objects.push(o);
|
||
};
|
||
const box = { width: 40, height: 40 };
|
||
put('phys-kicked', 'crate', '', -200, ${Y}, 'Kicked crate', box);
|
||
put('phys-kicked-col', 'phys-col-box', 'phys-kicked', 0, 0);
|
||
put('phys-kicked-rb', 'phys-rb-kicked', 'phys-kicked', 0, 0);
|
||
put('phys-kicked-kick', 'phys-kick', 'phys-kicked', 0, 0);
|
||
put('phys-walker', 'crate', '', 0, ${Y + 100}, 'Walker', box);
|
||
put('phys-walker-col', 'phys-col-box', 'phys-walker', 0, 0);
|
||
put('phys-walker-walk', 'phys-walk', 'phys-walker', 0, 0);
|
||
put('phys-pushed', 'crate', '', 60, ${Y + 100}, 'Pushed crate', box);
|
||
put('phys-pushed-col', 'phys-col-box', 'phys-pushed', 0, 0);
|
||
put('phys-pushed-rb', 'phys-rb-free', 'phys-pushed', 0, 0);
|
||
__gm.state.select(null)`);
|
||
await play();
|
||
// A model event while playing re-reads the scene (CI had one in the first frames): what the run
|
||
// set with Set gravity has to outlive it.
|
||
await evaluate(`__gm.state.preview()`);
|
||
await evaluate(`(() => {
|
||
const out = (window.__physicsGraph = []);
|
||
const start = performance.now();
|
||
const sample = () => {
|
||
const w = __gm.world();
|
||
if (!w) return;
|
||
const at = (id) => w.entities.find((e) => e.objectId === id);
|
||
const kicked = at('phys-kicked');
|
||
out.push({
|
||
t: (performance.now() - start) / 1000,
|
||
kickedX: kicked?.worldX,
|
||
kickedY: kicked?.worldY,
|
||
walker: at('phys-walker')?.worldX,
|
||
pushed: at('phys-pushed')?.worldX,
|
||
});
|
||
if (performance.now() - start < 2000) requestAnimationFrame(sample);
|
||
};
|
||
requestAnimationFrame(sample);
|
||
})()`);
|
||
await sleep(2300);
|
||
const run = await json('window.__physicsGraph');
|
||
await stop();
|
||
|
||
const at = (seconds) =>
|
||
run.reduce((best, s) => (Math.abs(s.t - seconds) < Math.abs(best.t - seconds) ? s : best));
|
||
const [early, late] = [at(0.5), at(1.5)];
|
||
const glide = (late.kickedX - early.kickedX) / (late.t - early.t);
|
||
const speed = KICK / KICKED_MASS;
|
||
check(
|
||
`Set gravity and Apply impulse in On start: the crate glides straight at impulse ÷ mass (${speed} per second)`,
|
||
run.length > 60 &&
|
||
Math.abs(glide - speed) < speed * 0.05 &&
|
||
run.every((s) => Math.abs(s.kickedY - Y) < 0.5),
|
||
JSON.stringify({ frames: run.length, glide: Math.round(glide * 10) / 10, early, late }),
|
||
);
|
||
const gap = Math.min(...run.map((s) => s.pushed - 20 - (s.walker + 20)));
|
||
const pushed = run.at(-1).pushed - 60;
|
||
check(
|
||
'Move with "Collide with solids" pushes the crate in its way and never gets into it',
|
||
pushed > 100 && gap > -0.01,
|
||
JSON.stringify({
|
||
pushed: Math.round(pushed * 10) / 10,
|
||
walked: Math.round(run.at(-1).walker * 10) / 10,
|
||
gap,
|
||
}),
|
||
);
|
||
const hints = consoleLog.filter((line) => line.includes('[physics]')).slice(hintsBefore);
|
||
check('nothing in the step made the physics hint at a no-op', hints.length === 0, hints.join(' | '));
|
||
|
||
const clearPhysics = () =>
|
||
mutate(`
|
||
const p = __gm.state.project;
|
||
const scene = __gm.state.activeScene;
|
||
scene.objects = scene.objects.filter((o) => !o.id.startsWith('phys-'));
|
||
p.assets = p.assets.filter((a) => !a.id.startsWith('phys-'));
|
||
__gm.state.select(null)`);
|
||
await clearPhysics();
|
||
|
||
// 41d. Move along the contact normal (#150): a ball walked straight right into the upper-left
|
||
// corner of a block, 12 below its centre, rolls up over it instead of hanging on the corner,
|
||
// and its graph logs the normal of the first contact from Move's hit / normal outputs.
|
||
const edgeLog = consoleLog.length;
|
||
await mutate(`
|
||
const scene = __gm.state.activeScene;
|
||
const assets = __gm.state.project.assets;
|
||
${MAKE_OBJECT}
|
||
const asset = structuredClone(assets.find((a) => a.id === 'mover'));
|
||
Object.assign(asset, { id: 'phys-roll', name: 'Physics roll', props: { enabled: true, runInEditor: false }, variables: [], graph: ${JSON.stringify(rollGraph)} });
|
||
assets.push(asset);
|
||
assets.push({ id: 'phys-col-ball', type: 'collider', name: 'phys-col-ball', props: { shape: 'circle', width: 40, height: 40, radius: ${BALL_R}, isTrigger: false } });
|
||
assets.push({ id: 'phys-col-block', type: 'collider', name: 'phys-col-block', props: { shape: 'box', width: 100, height: 60, radius: 20, isTrigger: false } });
|
||
const put = (id, assetId, parentId, x, y, name, overrides = {}) => {
|
||
const o = make(id, assetId, parentId, y, name);
|
||
o.position.x = x;
|
||
o.overrides = overrides;
|
||
scene.objects.push(o);
|
||
};
|
||
put('phys-ball', 'crate', '', 0, ${Y}, 'Rolling ball', { width: 40, height: 40 });
|
||
put('phys-ball-col', 'phys-col-ball', 'phys-ball', 0, 0);
|
||
put('phys-ball-roll', 'phys-roll', 'phys-ball', 0, 0, 'Roll');
|
||
put('phys-block', 'crate', '', 150, ${Y + CORNER_DROP + 30}, 'Edge block', { width: 100, height: 60 });
|
||
put('phys-block-col', 'phys-col-block', 'phys-block', 0, 0);
|
||
__gm.state.select(null)`);
|
||
await play();
|
||
await sleep(2000);
|
||
const ball = await json(
|
||
`(() => { const e = __gm.world()?.entities.find((x) => x.objectId === 'phys-ball'); return e && { x: e.worldX, y: e.worldY }; })()`,
|
||
);
|
||
await stop();
|
||
const top = Y + CORNER_DROP - BALL_R; // the ball's centre when it rolls along the block's top face
|
||
check(
|
||
'Move rolls a ball up over the edge it runs into, onto the top face',
|
||
ball && ball.x > 150 && ball.y <= top + 0.01 && ball.y > top - 3,
|
||
JSON.stringify({ ball, top }),
|
||
);
|
||
const lines = consoleLog.slice(edgeLog).filter((line) => line.startsWith('[behavior '));
|
||
const logged = lines
|
||
.filter((line) => line.startsWith('[behavior Roll]'))
|
||
.map((line) => Number(line.split(']').pop()));
|
||
const reach = Math.sqrt(BALL_R ** 2 - CORNER_DROP ** 2);
|
||
check(
|
||
"Move's hit and normal outputs: the first contact faces from the corner to the ball's centre",
|
||
logged.length >= 2 &&
|
||
Math.abs(logged[0] + reach / BALL_R) < 1e-6 &&
|
||
Math.abs(logged[1] + CORNER_DROP / BALL_R) < 1e-6,
|
||
JSON.stringify({
|
||
logged: logged.slice(0, 4),
|
||
expected: [-reach / BALL_R, -CORNER_DROP / BALL_R],
|
||
lines: lines.slice(0, 4),
|
||
}),
|
||
);
|
||
await clearPhysics();
|
||
}
|