191 lines
7.9 KiB
JavaScript
191 lines
7.9 KiB
JavaScript
import { LANES, MAKE_OBJECT } from './lib/scaffold.mjs';
|
||
|
||
/**
|
||
* 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: the highest point between two landings.
|
||
const peaks = [];
|
||
let landings = 0;
|
||
let down = false;
|
||
let top = Infinity;
|
||
for (const { ball } of samples) {
|
||
if (ball > REST - 1) {
|
||
if (!down && landings++ > 0 && Number.isFinite(top)) peaks.push(REST - top);
|
||
down = true;
|
||
top = Infinity;
|
||
} else {
|
||
down = false;
|
||
if (landings > 0) top = Math.min(top, ball);
|
||
}
|
||
}
|
||
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)`);
|
||
}
|